diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e11be9..e94f498 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,7 +33,10 @@ configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/versi # Sources comuns del sistema d'entitats: s'afegeixen tant a l'executable # principal com al smoke test. Mantenir-los en variable separada evita # duplicació entre els dos targets. -set(ENTITY_SOURCES +# Entitats sense dependència directa amb Player.cpp: les enllacen tant +# l'executable principal com el test_entities_smoke (que evita arrossegar +# l'stack del jugador i les seues dependències pesades). +set(ENTITY_SOURCES_BASE source/game/entities/entity.cpp source/game/entities/animation_component.cpp source/game/entities/event_bus.cpp @@ -50,6 +53,13 @@ set(ENTITY_SOURCES source/game/entities/concrete/dust.cpp ) +# Entitats que criden directament símbols de Player (com WhipAttack via +# `enterHurt`/`getBaseBox`): només van a l'executable principal. +set(ENTITY_SOURCES + ${ENTITY_SOURCES_BASE} + source/game/entities/concrete/whip_attack.cpp +) + set(APP_SOURCES source/main.cpp source/core/system/director.cpp @@ -338,7 +348,7 @@ if(BUILD_TESTS) source/core/resources/resource_helper.cpp source/core/resources/resource_pack.cpp source/game/map/map.cpp - ${ENTITY_SOURCES} + ${ENTITY_SOURCES_BASE} ) target_include_directories(test_entities_smoke PRIVATE "${CMAKE_SOURCE_DIR}/source" @@ -351,6 +361,29 @@ if(BUILD_TESTS) COMMAND test_entities_smoke WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + # Smoke del WhipAttack. Inclou tot l'stack d'entitats + whip_attack.cpp. + # `whip_attack_player_stubs.cpp` proveeix símbols mínims de Player per + # satisfer el linker sense arrossegar audio/gamepad/screen — els tests + # del whip s'enfoquen a hitbox + Mummy, no a friendly fire. + add_executable(test_whip_attack + tests/test_whip_attack.cpp + tests/whip_attack_player_stubs.cpp + source/core/resources/resource_helper.cpp + source/core/resources/resource_pack.cpp + source/game/map/map.cpp + ${ENTITY_SOURCES_BASE} + source/game/entities/concrete/whip_attack.cpp + ) + target_include_directories(test_whip_attack PRIVATE + "${CMAKE_SOURCE_DIR}/source" + "${CMAKE_BINARY_DIR}" + ) + target_link_libraries(test_whip_attack PRIVATE SDL3::SDL3) + target_compile_options(test_whip_attack PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME whip_attack + COMMAND test_whip_attack + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + # Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone: # només enllaça els helpers purs (debug_config, placeholder_input, # key_config) perquè la integració completa dels placeholders arrossegaria diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 41f6bf3..c3743e7 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -38,3 +38,13 @@ ai: stats: speed: 30 damage: 1 + +# Estat HURT: post-fuetada, fugint en línia recta. Si la direcció +# preferida (la del whip) està bloquejada, prova perpendiculars; si totes +# ho estan, queda quieta tot el `duration_ms` i després torna a ROAMING. +# `speed` opcional: si no apareix, s'usa `stats.speed`. El `whippable` +# habilita la reacció al látigo (false ⇒ el whip la ignora). +hurt: + duration_ms: 600 + speed: 60 + whippable: true diff --git a/data/entities/whip_attack/whip_attack.yaml b/data/entities/whip_attack/whip_attack.yaml new file mode 100644 index 0000000..38edc0b --- /dev/null +++ b/data/entities/whip_attack/whip_attack.yaml @@ -0,0 +1,38 @@ +name: whip_attack + +# Quatre sprites distints, un per cardinal. El sistema d'animació actual +# només permet UNA mida de frame per surface, i els sprites del látigo +# tenen aspect ratios distints segons direcció. Per això són 4 fitxers +# en lloc d'una sola sheet compartida amb flips. +# +# Mides (= `draw_rect` del WhipAttack, que NO es retalla per +# bloquejadors): +# E/W → 80×32 px per frame: 16 px de wind-up darrere del jugador +# + 16 px de solapament + 48 px d'atac +# frontal. Cobreix tota l'altura del jugador. +# N/S → 16×96 px per frame: 48 px d'atac + 32 px solapant amb el jugador +# + 16 px de wind-up "darrere". +# +# La caja de DANY és independent i més petita (48×16 / 16×48), pegada +# al borde del jugador i retallada per murs/sarcs. La calcula +# `WhipAttack::configure` a partir del `owner_box` del payload. +sprites: + east: { path: whip_east.gif, size: [80, 32] } + west: { path: whip_west.gif, size: [80, 32] } + north: { path: whip_north.gif, size: [16, 96] } + south: { path: whip_south.gif, size: [16, 96] } + +# 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 0000000..be38641 Binary files /dev/null and b/data/entities/whip_attack/whip_east.gif differ diff --git a/data/entities/whip_attack/whip_north.gif b/data/entities/whip_attack/whip_north.gif new file mode 100644 index 0000000..647f924 Binary files /dev/null and b/data/entities/whip_attack/whip_north.gif differ diff --git a/data/entities/whip_attack/whip_south.gif b/data/entities/whip_attack/whip_south.gif new file mode 100644 index 0000000..53780dc Binary files /dev/null and b/data/entities/whip_attack/whip_south.gif differ diff --git a/data/entities/whip_attack/whip_west.gif b/data/entities/whip_attack/whip_west.gif new file mode 100644 index 0000000..dfacc3b Binary files /dev/null and b/data/entities/whip_attack/whip_west.gif differ diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 9f6806d..716dd65 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -68,6 +68,36 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + # Placeholder mentre no arribe l'art real (el GIF de morcus encara no té + # frames d'atac específics — reaprofitem `walk_*`). El bloc està declarat + # perquè `Player::animationName` el busque sense fallback i la mecànica + # de l'atac estiga lligada amb morcus tan bon punt s'afegisquen els + # frames; ara mateix visualment el sprite caminarà mentre llança el + # látigo (acceptat per al placeholder). + attack_down: + frames: [0, 1, 2, 3] + frame_ms: 50 + + attack_up: + frames: [4, 5, 6, 7] + frame_ms: 50 + + attack: + frames: [8, 9, 10, 11] + frame_ms: 50 + + # Cooldown post-swing (recovery): igual que a sam.yaml, placeholder als + # frames de `stand_*`. Quan arriben els frames reals només cal canviar + # els índexs ací. + recover_down: + frames: [0] + + recover_up: + frames: [4] + + recover: + frames: [8] + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -127,3 +157,11 @@ jump: hurt: speed: 120 brake_time_ms: 500 + +# Configuració del látigo. Mateixos valors que Sam de moment; +# diferenciar quan equilibrem cada personatge. +whip: + range_tiles: 3 + swing_ms: 200 + cooldown_ms: 200 + damage: 1 diff --git a/data/player/sam.gif b/data/player/sam.gif index db9f574..943e292 100644 Binary files a/data/player/sam.gif and b/data/player/sam.gif differ diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 0b22f7f..31cacf2 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -68,6 +68,36 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + # Animacions del braç llançant el látigo. Placeholder: reaprofiten els + # frames de `walk_*` perquè els sprites reals encara no existeixen + # (TODO: dibuixar-ne d'específics quan arribe l'art). El sprite del + # látigo viu al `WhipAttack` i es renderitza superposat per z-order. + attack_down: + frames: [28, 29, 30, 31] + frame_ms: 50 + + attack_up: + frames: [24, 25, 26, 27] + frame_ms: 50 + + attack: + frames: [20, 21, 22, 23] + frame_ms: 50 + + # Animacions de cooldown post-swing (recovery): mentre dura + # `whip_cooldown_ms` el jugador continua bloquejat però l'animació ja + # no és la d'atac. Placeholder: reaprofiten els frames de `stand_*` + # (la silueta no canvia respecte d'ells); quan arriben els frames + # reals només cal canviar els índexs ací, sense tocar el codi. + recover_down: + frames: [1] + + recover_up: + frames: [5] + + recover: + frames: [9] + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -127,3 +157,16 @@ jump: hurt: speed: 120 brake_time_ms: 500 + +# Configuració del látigo. `range_tiles` és la longitud de la hitbox +# abans de retallar contra murs/sarcs (3 tiles ≈ 48 px). `swing_ms` és +# la durada de l'animació de l'atac, durant la qual la hitbox del whip +# està viva. `cooldown_ms` allarga ATTACKING amb una recuperació en què +# el jugador segueix bloquejat però la hitbox ja ha desaparegut: castiga +# l'ús abusiu del látigo. `damage` queda llegit per a futur (els +# objectius reben `enterHurt` directament; cap entitat té HP encara). +whip: + range_tiles: 3 + swing_ms: 200 + cooldown_ms: 200 + damage: 1 diff --git a/source/core/rendering/sprite/moving_sprite.hpp b/source/core/rendering/sprite/moving_sprite.hpp index f537361..8f97f91 100644 --- a/source/core/rendering/sprite/moving_sprite.hpp +++ b/source/core/rendering/sprite/moving_sprite.hpp @@ -38,7 +38,7 @@ class MovingSprite : public Sprite { void setAccelY(float value) { ay_ = value; } void setFlip(SDL_FlipMode flip) { flip_ = flip; } - auto getFlip() -> SDL_FlipMode { return flip_; } + [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; } // Moviment eix-separat amb col·lisió: aplica `dx` (i si la nova 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/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 1edef50..43977d5 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -104,6 +104,22 @@ Mummy::Mummy(const fkyaml::node& yaml) } else if (initial == "down") { changeDirection(0.0F, 1.0F); } + + // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la + // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la + // pot ignorar amb `isWhippable() == false` si volem que no reacció. + if (yaml.contains("hurt")) { + const auto& h = yaml["hurt"]; + if (h.contains("duration_ms")) { + hurt_config_.duration_s = static_cast(h["duration_ms"].get_value()) / 1000.0F; + } + if (h.contains("speed")) { + hurt_config_.speed_px_per_s = h["speed"].get_value(); + } + if (h.contains("whippable")) { + whippable_ = h["whippable"].get_value(); + } + } } void Mummy::changeDirection(float dx, float dy) { @@ -305,4 +321,118 @@ void Mummy::onPlayerContact(Player* player) { } } +void Mummy::enterHurt(Direction flee_dir) { + state_ = State::HURT; + hurt_remaining_s_ = hurt_config_.duration_s; + hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; + + const Direction CHOSEN = chooseHurtDirection(flee_dir); + if (CHOSEN == Direction::NONE) { + // Cap direcció lliure: la mòmia queda quieta però en HURT durant + // tot el timer. Posem dir a 0 perquè `hurtStep` no es mou. + setDirection(0.0F, 0.0F); + } else { + applyChoice(CHOSEN); + } +} + +auto Mummy::chooseHurtDirection(Direction preferred) const -> Direction { + // Avenç ~1 frame a 60 FPS amb la velocitat de HURT. Tan curt com + // perquè un canvi sub-tile no decideixe; suficient perquè un mur + // adjacent (o sarc tocant la caja) es detecte. + const float TEST_SPEED = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; + const float STEP = TEST_SPEED * (1.0F / 60.0F); + + auto is_open = [this, STEP](Direction d) -> bool { + float dx = 0.0F; + float dy = 0.0F; + switch (d) { + case Direction::UP: + dy = -STEP; + break; + case Direction::DOWN: + dy = STEP; + break; + case Direction::LEFT: + dx = -STEP; + break; + case Direction::RIGHT: + dx = STEP; + break; + case Direction::NONE: + return false; + } + const auto BOX = candidateBox(dx, dy); + return Map::insidePlayfield(BOX) && !Map::collides(BOX); + }; + + if (preferred != Direction::NONE && is_open(preferred)) { return preferred; } + + // Perpendiculars segons la direcció preferida (ordre arbitrari però + // consistent: "esquerra del whip" primer). + Direction left = Direction::NONE; + Direction right = Direction::NONE; + switch (preferred) { + case Direction::UP: + left = Direction::LEFT; + right = Direction::RIGHT; + break; + case Direction::DOWN: + left = Direction::RIGHT; + right = Direction::LEFT; + break; + case Direction::LEFT: + left = Direction::DOWN; + right = Direction::UP; + break; + case Direction::RIGHT: + left = Direction::UP; + right = Direction::DOWN; + break; + case Direction::NONE: + return Direction::NONE; + } + if (is_open(left)) { return left; } + if (is_open(right)) { return right; } + + return Direction::NONE; +} + +void Mummy::hurtStep(float delta_time) { + // Si està quieta (totes les direccions bloquejades a `enterHurt`), no + // avança ni surt: l'únic camí de sortida és el timer. + if (dir_x_ == 0.0F && dir_y_ == 0.0F) { return; } + + const float DX = dir_x_ * hurt_speed_ * delta_time; + const float DY = dir_y_ * hurt_speed_ * delta_time; + const auto CANDIDATE = candidateBox(DX, DY); + if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) { + pixel_rect_.x += DX; + pixel_rect_.y += DY; + return; + } + + // Topa amb mur o sarc: surt del HURT immediatament. La IA normal + // re-decideix al pròxim step (no fem U-turn ni re-orientació aquí). + state_ = State::ROAMING; + hurt_remaining_s_ = 0.0F; +} + +void Mummy::update(float delta_time) { + if (state_ == State::HURT) { + // Avancem l'animació inline (no podem cridar `Entity::update` ni + // `DynamicHazard::update`: el primer salta una capa virtual i el + // segon dispararia `step()` amb la lògica d'IA). Volem només el + // tick d'animació + el moviment recta del HURT. + if (anim_.has_value()) { anim_->tick(delta_time); } + hurtStep(delta_time); + hurt_remaining_s_ -= delta_time; + if (hurt_remaining_s_ <= 0.0F && state_ == State::HURT) { + state_ = State::ROAMING; + } + return; + } + DynamicHazard::update(delta_time); +} + REGISTER_ENTITY("mummy", Mummy) diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index 937f763..e7ab31a 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -21,6 +21,21 @@ class Mummy : public DynamicHazard { LEFT, RIGHT }; + // Estat d'alt nivell. ROAMING = IA normal (corridor_wanderer); HURT = + // post-fuetada, fugint en línia recta durant `hurt_config_.duration_s`. + // Mentre dura el HURT no decideix a creuaments ni canvia de direcció; + // si topa amb mur o sarc, surt del HURT abans de l'expiració del timer. + enum class State : std::uint8_t { ROAMING, + HURT }; + + // Configuració de l'estat HURT, llegida del bloc `hurt:` del YAML. + // Si `duration_s == 0` la mòmia mai entra a HURT efectiu (defensiu). + // `speed_px_per_s == 0` ⇒ s'usa la velocitat normal de la mòmia. + struct HurtConfig { + float duration_s{0.0F}; + float speed_px_per_s{0.0F}; + }; + explicit Mummy(const fkyaml::node& yaml); // Hook d'IA. Donat un conjunt de direccions admissibles, decideix la @@ -39,9 +54,22 @@ class Mummy : public DynamicHazard { // En ambdós casos el frame de gir no consumeix avenç extra. void step(float delta_time) override; + // Update sencer: afegim la branca HURT (avanç del timer + step recta). + // En estat ROAMING delega a `step()` com sempre via la base. + void update(float delta_time) override; + // Contacte amb el jugador: publica `player_hit` amb el dany. void onPlayerContact(Player* player) override; + // Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`. + // Si està bloquejada, prova perpendiculars; si totes estan bloquejades, + // la mòmia queda quieta però en HURT durant tot el timer. + void enterHurt(Direction flee_dir); + + [[nodiscard]] auto getState() const -> State { return state_; } + [[nodiscard]] auto isWhippable() const -> bool { return whippable_; } + [[nodiscard]] auto getHurtRemainingS() const -> float { return hurt_remaining_s_; } + private: // Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy). [[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect; @@ -90,9 +118,28 @@ class Mummy : public DynamicHazard { // S'usa al pool de creuament per afegir la recta com a candidata. [[nodiscard]] auto currentDirection() const -> Direction; + // Avança recta a `hurt_speed_ * delta_time`. Si la caixa candidata + // sortiria del playfield o trepitjaria un sarc, marca la sortida del + // HURT abans de l'expiració del timer (la mòmia no rebota). + void hurtStep(float delta_time); + + // Tria la direcció definitiva del HURT donada la preferida (la del + // whip). Si la preferida està bloquejada, prova perpendiculars; si + // 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_; + + // Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva + // (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la + // velocitat normal de la mòmia). + State state_{State::ROAMING}; + HurtConfig hurt_config_; + bool whippable_{true}; + float hurt_remaining_s_{0.0F}; + float hurt_speed_{0.0F}; }; diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp new file mode 100644 index 0000000..0c9c6cd --- /dev/null +++ b/source/game/entities/concrete/whip_attack.cpp @@ -0,0 +1,342 @@ +#include "game/entities/concrete/whip_attack.hpp" + +#include +#include +#include +#include + +#include "game/entities/concrete/mummy.hpp" +#include "game/entities/entity_factory.hpp" +#include "game/entities/entity_manager.hpp" +#include "game/entities/player.hpp" +#include "game/map/map.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, const SDL_FRect& owner_box, float range_px) { + direction_ = dir; + range_px_ = range_px; + + // Draw rect (= pixel_rect_): cobreix tot el sprite del látigo segons + // la taula d'offsets del disseny: + // E: (player.x - 16, player.y ), 80×32 + // W: (player.x - 48, player.y ), 80×32 + // N: (player.x, player.y - 48 ), 16×96 + // S: (player.x, player.y - 16 ), 16×96 + const auto& chosen = spriteFor(dir); + SDL_FRect draw{0.0F, 0.0F, chosen.frame_w, chosen.frame_h}; + switch (dir) { + case Direction::EAST: + draw.x = owner_box.x - 16.0F; + draw.y = owner_box.y; + break; + case Direction::WEST: + draw.x = owner_box.x - 48.0F; + draw.y = owner_box.y; + break; + case Direction::NORTH: + draw.x = owner_box.x; + draw.y = owner_box.y - 48.0F; + break; + case Direction::SOUTH: + draw.x = owner_box.x; + draw.y = owner_box.y - 16.0F; + break; + } + setPixelRect(draw); + + // Damage rect: 48×16 (E/W) o 16×48 (N/S) al nivell dels peus del + // jugador (els últims 16 px del seu pixel_rect — convenció estàndard + // del player AEE), pegat al borde en la direcció d'atac. + // computeDamageRect rep la cantonada del rect MÀXIM (sense retallar) + // i recorre internament la línia per topar amb el primer bloquejador. + const float COLLISION_TOP_Y = owner_box.y + owner_box.h - 16.0F; + const float OWNER_RIGHT = owner_box.x + owner_box.w; + const float OWNER_BOTTOM = owner_box.y + owner_box.h; + float spawn_x = 0.0F; + float spawn_y = 0.0F; + switch (dir) { + case Direction::EAST: + spawn_x = OWNER_RIGHT; + spawn_y = COLLISION_TOP_Y; + break; + case Direction::WEST: + spawn_x = owner_box.x - range_px; + spawn_y = COLLISION_TOP_Y; + break; + case Direction::NORTH: + spawn_x = owner_box.x; + spawn_y = COLLISION_TOP_Y - range_px; + break; + case Direction::SOUTH: + spawn_x = owner_box.x; + spawn_y = OWNER_BOTTOM; + break; + } + computeDamageRect(dir, spawn_x, spawn_y, range_px); + + // Snapshot del feet_y del jugador. La convenció estàndard del Player + // AEE té el seu feet_y a la base inferior del pixel_rect (collision + // 16×16 als últims 16 px del sprite 16×32), així que owner_box.y + + // owner_box.h reprodueix `Player::getFeetY()` exactament. + owner_feet_y_ = owner_box.y + owner_box.h; + + if (anim_.has_value()) { anim_->reset(); } +} + +auto WhipAttack::getFeetY() const -> float { + return owner_feet_y_; +} + +void WhipAttack::setWorldContext(EntityManager* manager, const std::vector* players) { + entity_manager_ = manager; + players_ = players; +} + +void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px) { + // Caminem píxel a píxel des de la vora del jugador en la direcció + // d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`. + // L'amplada perpendicular és `tile_size` (els passadissos del joc + // tenen exactament aquesta mida i l'atac és lateral). + const auto TILE = static_cast(Map::tile_size); + const int MAX_PX = static_cast(std::max(0.0F, range_px)); + const auto MAX = static_cast(MAX_PX); + float length = 0.0F; + + auto try_probe = [&](float l) -> bool { + SDL_FRect probe{0.0F, 0.0F, 0.0F, 0.0F}; + switch (dir) { + case Direction::EAST: + probe = {.x = spawn_x, .y = spawn_y, .w = l, .h = TILE}; + break; + case Direction::WEST: + // Per a W, la cara "del jugador" és la dreta; com creix + // `l`, la vora esquerra retrocedeix. spawn_x apunta al + // límit esquerre amb l = MAX (cas no retallat). + probe = {.x = spawn_x + (MAX - l), .y = spawn_y, .w = l, .h = TILE}; + break; + case Direction::SOUTH: + probe = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = l}; + break; + case Direction::NORTH: + probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l}; + break; + } + return Map::insidePlayfield(probe) && !Map::collides(probe); + }; + + for (int i = 1; i <= MAX_PX; ++i) { + const auto L = static_cast(i); + if (try_probe(L)) { + length = L; + } else { + break; + } + } + + switch (dir) { + case Direction::EAST: + damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = length, .h = TILE}; + return; + case Direction::WEST: + damage_rect_ = {.x = spawn_x + (MAX - length), .y = spawn_y, .w = length, .h = TILE}; + return; + case Direction::SOUTH: + damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = length}; + return; + case Direction::NORTH: + damage_rect_ = {.x = spawn_x, .y = spawn_y + (MAX - length), .w = TILE, .h = length}; + return; + } +} + +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 (isFrameActive()) { applyDamage(); } + if (anim_.has_value() && anim_->completed()) { markDead(); } +} + +namespace { + + auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction { + switch (d) { + case WhipAttack::Direction::NORTH: + return Mummy::Direction::UP; + case WhipAttack::Direction::SOUTH: + return Mummy::Direction::DOWN; + case WhipAttack::Direction::EAST: + return Mummy::Direction::RIGHT; + case WhipAttack::Direction::WEST: + return Mummy::Direction::LEFT; + } + return Mummy::Direction::NONE; + } + + auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal { + switch (d) { + case WhipAttack::Direction::NORTH: + return Player::Cardinal::NORTH; + case WhipAttack::Direction::SOUTH: + return Player::Cardinal::SOUTH; + case WhipAttack::Direction::EAST: + return Player::Cardinal::EAST; + case WhipAttack::Direction::WEST: + return Player::Cardinal::WEST; + } + return Player::Cardinal::NONE; + } + + auto playerIsTargetable(const Player* p) -> bool { + if (p == nullptr) { return false; } + const auto STATE = p->getState(); + return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED; + } + + // Tracta una mòmia candidata: si és whippable i la seua caja solapa + // amb `damage_rect`, la posa a HURT i la marca al set. Extret per + // acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). + void hitMummyIfApplicable( + Entity* e, + const SDL_FRect& damage_rect, + WhipAttack::Direction dir, + std::unordered_set& already_hit) { + if (e == nullptr || e->isDead()) { return; } + if (already_hit.contains(static_cast(e))) { return; } + auto* mummy = dynamic_cast(e); + if (mummy == nullptr || !mummy->isWhippable()) { return; } + const auto BOX = mummy->getCollisionRect(); + if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } + mummy->enterHurt(whipDirToMummyDir(dir)); + already_hit.insert(static_cast(e)); + } + + void hitPlayerIfApplicable( + Player* p, + const Player* owner, + const SDL_FRect& damage_rect, + WhipAttack::Direction dir, + std::unordered_set& already_hit) { + if (p == owner || !playerIsTargetable(p)) { return; } + if (already_hit.contains(static_cast(p))) { return; } + const auto BOX = p->getBaseBox(); + if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } + p->enterHurt(whipDirToPlayerCardinal(dir)); + already_hit.insert(static_cast(p)); + } + +} // namespace + +void WhipAttack::applyDamage() { + if (entity_manager_ != nullptr) { + for (auto* e : entity_manager_->getEntities()) { + hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); + } + } + if (players_ != nullptr) { + for (auto* p : *players_) { + hitPlayerIfApplicable(p, owner_, damage_rect_, direction_, already_hit_); + } + } +} + +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..6a34ed3 --- /dev/null +++ b/source/game/entities/concrete/whip_attack.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "external/fkyaml_node.hpp" +#include "game/entities/animation_component.hpp" +#include "game/entities/attack.hpp" + +class Entity; +class EntityManager; +class Player; + +// 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 (sense flips). La +// mida del frame canvia entre direccions (E/W: 80×32, N/S: 16×96), +// cosa que el sistema d'animació actual no permet expressar amb una +// sola surface. Per això el YAML llista les 4 entrades sota `sprites:` +// i `configure()` tria la que toca segons direcció. +// +// Dos rectangles distints, intencionadament diferenciats: +// - `pixel_rect_` (heretat d'`Entity`) = `draw_rect`. Cobreix tot el +// sprite del látigo: 16 px de wind-up darrere del jugador, 16 px +// de solapament amb el jugador, i 48 px d'atac frontal (per E/W; +// proporcions equivalents per N/S amb 32 px de solapament). NO es +// retalla per bloquejadors — només render visual. +// - `damage_rect_` = caixa de dany al nivell dels peus del jugador +// (centrada sobre la base 16×16). 48×16 (E/W) o 16×48 (N/S), +// pegada al borde del jugador en la direcció d'atac. SÍ es retalla +// pel primer bloquejador (mur extern del playfield o sarcòfag). +// +// Z-order: `getFeetY()` delega al `owner_` per garantir que el látigo +// quede al mateix bucket d'y-sort que el jugador que l'ha llançat. Si +// no, l'altura física del sprite (esp. en N/S, que té 96 px) crearia +// solapaments raros amb mòmies a la trajectòria. +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`. A partir del `owner_box` (pixel_rect del jugador, + // típicament 16×32) i la direcció d'atac, deriva: + // - `pixel_rect_` (= draw_rect): cantonada i mida del sprite del + // látigo segons la taula d'offsets (E: -16, 0, 80×32; etc.). + // - `damage_rect_`: caixa de dany 48×16 (o 16×48) pegada al + // borde del jugador, retallada contra murs i sarcs. + // Convenció implícita: el jugador té collision rect 16×16 als + // últims 16 px del seu pixel_rect (els "peus"); aquesta funció + // n'usa la geometria per al damage_rect i el feet_y delegat. + void configure(Direction dir, const SDL_FRect& owner_box, float range_px); + + // Injecta el context que necessita la fase de dany: punter a + // l'`EntityManager` (per iterar Hazards) i a la llista de jugadors + // (per al friendly fire). Si no es crida, els frames actius no + // apliquen dany — el whip queda merament visual. Cridar abans del + // primer `update` (típicament just després de `configure`). + void setWorldContext(EntityManager* manager, const std::vector* players); + + // Avança animació; durant frames actius, aplica dany; quan + // l'animació es 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_; } + + // Hitbox efectiva de dany (ja retallada pel primer bloquejador en la + // línia d'atac). Exposada per als smoke tests; en producció només la + // consumeix la pròpia `applyDamage`. + [[nodiscard]] auto getDamageRect() const -> SDL_FRect { return damage_rect_; } + + // Y dels peus delegada al `owner_box` capturat a `configure()`: el + // látigo es z-ordena al mateix bucket que el jugador que l'ha + // llançat (= base inferior del seu pixel_rect, que coincideix amb + // el feet_y del jugador a la convenció estàndard del player AEE). + // Snapshot, no consulta dinàmica: el jugador queda quiet durant + // ATTACKING, així que snapshot ≡ live per a la vida del whip. + [[nodiscard]] auto getFeetY() const -> float override; + + // 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&; + + // Calcula la `damage_rect_` final: retalla la longitud de l'atac + // contra el primer bloquejador (mur extern del playfield o sarc) en + // la línia d'atac. Implementació iterativa píxel a píxel — el rang + // típic és ~48 px, així que el cost és menyspreable. + void computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px); + + // Aplica dany aquest frame: itera Hazards (Mummy whippable) i + // jugadors (excepte `owner_`) que solapen amb `damage_rect_` i + // estan en estat "atacable", crida `enterHurt` amb la direcció + // adequada i els marca a `already_hit_` per garantir un sol + // impacte per swing per objectiu. + void applyDamage(); + + Direction direction_{Direction::EAST}; + AnimationComponent anim_swing_; + std::vector active_frames_; + // Set d'objectius ja colpejats aquest swing: usem `void*` perquè + // hi guardem tant `Entity*` (Mummy) com `Player*` (que NO hereta + // d'Entity) sense `reinterpret_cast` semànticament dubtós. + std::unordered_set already_hit_; + float range_px_{0.0F}; + SDL_FRect damage_rect_{0.0F, 0.0F, 0.0F, 0.0F}; + + // Snapshot del feet_y de l'owner pres a `configure()` (= base + // inferior del seu pixel_rect, que coincideix amb el `getFeetY()` + // del Player en la convenció estàndard d'AEE). El retornem des + // del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador. + float owner_feet_y_{0.0F}; + + EntityManager* entity_manager_{nullptr}; + const std::vector* players_{nullptr}; + + DirSprite sprite_north_; + DirSprite sprite_south_; + DirSprite sprite_east_; + DirSprite sprite_west_; +}; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 6d034ec..f7c7709 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -140,8 +140,10 @@ class Entity { // Y dels peus: la vora inferior de la caixa de col·lisió. La usarà el // Z-order per ordenar entitats i jugador de menys a més profunditat - // (REND-01, quan s'implemente). - [[nodiscard]] auto getFeetY() const -> float; + // (REND-01, quan s'implemente). Virtual perquè entitats efímeres + // (com `WhipAttack`) poden delegar al seu owner per quedar al + // mateix bucket d'y-sort que el jugador que les ha llançat. + [[nodiscard]] virtual auto getFeetY() const -> float; // El loader li assigna una caixa explícita llegida del YAML; sense // aquesta crida, l'entitat queda sense `CollisionBox` i la caixa de diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index 88b8e64..5d414db 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -12,6 +14,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 +52,31 @@ 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. +// - `owner_box` : pixel_rect del jugador (típicament 16×32 a AEE). El +// WhipAttack en deriva tant la `draw_rect_` (sprite del +// látigo, més gran que el jugador per cobrir wind-up i +// solapament) com la `damage_rect_` (caixa de dany al +// nivell dels peus, retallada per murs/sarcs). +// - `range_px` : longitud nominal de la hitbox de dany abans de +// retallar contra murs i sarcòfags. Llegida del YAML +// del jugador. +struct WhipSpawnPayload { + Player* owner{nullptr}; + int direction{0}; + SDL_FRect owner_box{0.0F, 0.0F, 0.0F, 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 +126,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/entities/player.cpp b/source/game/entities/player.cpp index 5efb701..443294f 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -68,6 +68,7 @@ Player::TurnConfig Player::turn_config; Player::InertiaConfig Player::inertia_config; Player::JumpConfig Player::jump_config; Player::HurtConfig Player::hurt_config; +Player::WhipConfig Player::whip_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); @@ -114,6 +115,11 @@ void Player::loadFrom(const std::string& yaml_path) { // Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed // és 0, l'estat HURT mai s'activa (cas degenerat). readHurtBlock(yaml); + + // Bloc opcional `whip:` — paràmetres del látigo. Si manca, el + // jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc + // d'input es perd). + readWhipBlock(yaml); } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { @@ -175,6 +181,16 @@ void Player::readHurtBlock(const fkyaml::node& yaml) { if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value(); } } +void Player::readWhipBlock(const fkyaml::node& yaml) { + whip_config = WhipConfig{}; + if (!yaml.contains("whip")) { return; } + const auto& w = yaml["whip"]; + if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value(); } + if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value(); } + if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value(); } + if (w.contains("damage")) { whip_config.damage = w["damage"].get_value(); } +} + void Player::applyCardinal(Cardinal c) { switch (c) { case Cardinal::NORTH: @@ -440,6 +456,7 @@ void Player::tick(float delta_time) { // durant el stun: així si l'usuari l'ha estat prement abans, no // se'l consumeix com a salt nou en sortir del stun. (void)consumeJumpEdge(); + (void)consumeAttackEdge(); return; } @@ -449,11 +466,36 @@ void Player::tick(float delta_time) { // pulsació quan torne a terra es detecte com a flanc nou. if (state_ == State::AIRBORNE) { (void)consumeJumpEdge(); + (void)consumeAttackEdge(); setVelX(intended_vx_); setVelY(intended_vy_); return; } + // En ATTACKING el jugador queda bloquejat tot el `swing_ms + + // cooldown_ms`: ni input direccional, ni salt, ni atac, ni canvi + // de facing. El timer descompta amb `delta_time * 1000` per + // mantenir la convenció de delta_ms de la resta d'estats temporals. + // Mantenim els flancs dels botons actualitzats perquè una + // tecla mantinguda no es comporte com a flanc nou en sortir. + if (state_ == State::ATTACKING) { + (void)consumeJumpEdge(); + (void)consumeAttackEdge(); + attacking_remaining_ms_ -= delta_time * 1000.0F; + if (attacking_remaining_ms_ <= 0.0F) { + attacking_remaining_ms_ = 0.0F; + state_ = State::GROUNDED; + } + input_vx_ = 0.0F; + input_vy_ = 0.0F; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + motion_time_ms_ = 0.0F; + setVelX(0.0F); + setVelY(0.0F); + return; + } + // En HURT el jugador està projectat sense control: descomptem // velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a // zero, tornem a GROUNDED i el frame següent ja torna a llegir input. @@ -461,6 +503,7 @@ void Player::tick(float delta_time) { // estats), que també gestiona el xoc contra murs. if (state_ == State::HURT) { (void)consumeJumpEdge(); + (void)consumeAttackEdge(); const float DECEL = (hurt_config.brake_time_ms > 0) ? hurt_config.initial_speed_px_per_s / (static_cast(hurt_config.brake_time_ms) / 1000.0F) : 0.0F; @@ -535,6 +578,18 @@ void Player::tick(float delta_time) { // només disparen amb intenció explícita del jugador. updateFacingAndTurn(delta_time, input_vx_, input_vy_); + // Detecció de flanc del botó d'atac. Es processa ABANS del salt + // perquè ATTACKING i AIRBORNE són mútuament excloents i, davant un + // mateix-frame, l'atac té prioritat (millor castigar la pulsació + // doble per excés que perdre l'atac per un toc accidental al salt). + // En entrar a ATTACKING, descartem el flanc del salt per no que + // queda penjat per a quan es desbloque. + if (consumeAttackEdge() && whip_config.swing_ms > 0) { + enterAttacking(currentFacingCardinal()); + (void)consumeJumpEdge(); + return; + } + // Detecció de flanc del botó de salt. Es processa DESPRÉS d'aplicar // el facing perquè el sprite del salt usa el facing del moment del // despegue. `being_pushed_` no ho pot bloquejar ací (encara no s'ha @@ -565,6 +620,81 @@ auto Player::consumeJumpEdge() -> bool { return edge; } +auto Player::consumeAttackEdge() -> bool { + // Mateix patró que `consumeJumpEdge` però per al botó d'atac. Mando: + // SDL_GAMEPAD_BUTTON_WEST (X a Xbox / Quadrat a PlayStation), oposat + // a SOUTH per evitar topades semàntiques. + const bool* keys = InputState::getKeyboardState(); + const bool KEY_NOW = keys[controls_.attack]; + bool edge = (KEY_NOW && !attack_held_keyboard_); + attack_held_keyboard_ = KEY_NOW; + + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); + const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_WEST); + if (PAD_NOW && !attack_held_gamepad_) { edge = true; } + attack_held_gamepad_ = PAD_NOW; + + return edge; +} + +auto Player::currentFacingCardinal() const -> Cardinal { + switch (facing_) { + case Facing::UP: + return Cardinal::NORTH; + case Facing::DOWN: + return Cardinal::SOUTH; + case Facing::SIDE: + break; + } + // 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; +} + +void Player::enterAttacking(Cardinal facing) { + state_ = State::ATTACKING; + attacking_remaining_ms_ = static_cast(whip_config.swing_ms + whip_config.cooldown_ms); + + // Garantim que facing/flip i cardinal queden coherents amb la + // direcció triada (per si l'atac s'ha disparat amb facing residual, + // p.ex. just després d'un canvi d'input). + applyCardinal(facing); + + if (facing == Cardinal::NONE) { return; } + + // Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`). + int dir_int = 0; + switch (facing) { + case Cardinal::NORTH: + dir_int = 1; + break; + case Cardinal::SOUTH: + dir_int = 2; + break; + case Cardinal::EAST: + dir_int = 3; + break; + case Cardinal::WEST: + dir_int = 4; + break; + case Cardinal::NONE: + return; + } + + // Passem el pixel_rect (16×32 a sam/morcus) al payload; el WhipAttack + // en deriva tant la draw_rect (sprite del látigo, més gran) com la + // damage_rect (al nivell dels peus, retallada per bloquejadors). + if (bus_ != nullptr) { + const float RANGE_PX = whip_config.range_tiles * static_cast(Map::tile_size); + bus_->publish("whip_spawn", WhipSpawnPayload{ + .owner = this, + .direction = dir_int, + .owner_box = getPosition(), + .range_px = RANGE_PX, + }); + } +} + void Player::startJump() { // No saltem si l'estat no ho permet. `being_pushed_` no és segur de // consultar ací (es pot haver marcat o no, segons l'ordre del frame), @@ -788,6 +918,15 @@ void Player::applyMovement(float delta_time) { return; } + if (state_ == State::ATTACKING) { + // Cap moviment durant ATTACKING. Animem (perquè avance la + // seqüència `attack_*`) i actualitzem la clau d'animació segons + // la sub-fase (swing vs cooldown). + animate(delta_time); + setCurrentAnimation(animationName(false)); + return; + } + // El resolver del GameScene pot haver escalat `intended_vx_/vy_` // (cas push: velocitat * push_factor) o haver-les anul·lat (cas // head-on). Consumim-les aquí com a velocitat definitiva del frame. @@ -989,6 +1128,37 @@ auto Player::animationName(bool moving) const -> const char* { } 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"; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index cd3b3db..ad2ad3e 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -24,6 +24,7 @@ class Player : public AnimatedSprite { SDL_Scancode left; SDL_Scancode right; SDL_Scancode jump; + SDL_Scancode attack; }; // Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal` @@ -44,6 +45,15 @@ class Player : public AnimatedSprite { AIRBORNE, LANDING_STUN, HURT, + // Atacant amb látigo. Estat mútuament exclusiu amb AIRBORNE: no + // es pot atacar saltant, ni saltar atacant. Cobreix swing + + // cooldown internament: durant `whip_swing_ms` es spawneja + // l'entitat WhipAttack i corre l'animació `attack_*`; durant + // `whip_cooldown_ms` posterior, el jugador segueix bloquejat + // (no input, no salt, no atac) però visualment torna a + // `stand_*`. En total, ATTACKING dura `swing_ms + cooldown_ms`; + // en acabar, torna a GROUNDED. + ATTACKING, // Jugador que ha eixit per la porta de la sala. Despawn lògic: // no es renderitza, no processa input, no es mou, no col·lisiona // amb res ni rep contacte d'entitats. Idempotent: les crides @@ -431,12 +441,16 @@ class Player : public AnimatedSprite { // 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: enum class Cardinal : std::uint8_t { NONE, NORTH, SOUTH, EAST, WEST }; + private: // Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb // `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i // el comportament és instantani: soltar input para el jugador en sec. @@ -503,6 +517,37 @@ class Player : public AnimatedSprite { // Llig el bloc `hurt:` del YAML i n'omple `hurt_config`. static void readHurtBlock(const fkyaml::node& yaml); + // Configuració del látigo del jugador llegida del bloc `whip:` del + // YAML. `range_tiles` defineix la longitud de la hitbox abans de + // retallar; `swing_ms` és la durada de l'animació d'atac (des + // d'`enterAttacking` fins a desbloquejar el cooldown); `cooldown_ms` + // afegeix una fase posterior durant la qual el jugador segueix + // bloquejat però la hitbox ja no fa dany. `damage` queda llegit per + // a futur; per ara els objectius reben sempre `enterHurt`. + struct WhipConfig { + float range_tiles{0.0F}; + int swing_ms{0}; + int cooldown_ms{0}; + int damage{0}; + }; + static WhipConfig whip_config; + + // Llig el bloc `whip:` del YAML i n'omple `whip_config`. Si manca el + // bloc, els camps queden a 0 i el jugador no pot atacar (el flanc + // d'input es perd silenciosament). + static void readWhipBlock(const fkyaml::node& yaml); + + // Activa l'estat ATTACKING en la direcció cardinal indicada (la + // direcció cap on mira el jugador) i publica `whip_spawn` al bus + // perquè el GameScene cree el `WhipAttack` corresponent. Posa el + // 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. + [[nodiscard]] auto currentFacingCardinal() const -> Cardinal; + // Activa l'estat HURT: el jugador queda projectat amb velocitat // inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria // així: @@ -513,8 +558,12 @@ class Player : public AnimatedSprite { // i tria el que dóna més recorregut lliure. // Decelera linealment fins a parar i, en eixe instant, torna a // GROUNDED. Sense col·lisió amb altres jugadors mentre dura. + // Pública: la dispara tant la cinemàtica de "caure damunt" com el + // `WhipAttack` quan colpeja un altre jugador (friendly fire). + public: void enterHurt(Cardinal preferred = Cardinal::NONE); + private: // Tria la direcció definitiva del HURT. Si `preferred` cabe el // recorregut natural complet, la retorna; d'altra manera tria la // direcció amb més recorregut lliure entre els 4 cardinals. @@ -549,10 +598,24 @@ class Player : public AnimatedSprite { bool jump_held_keyboard_{false}; bool jump_held_gamepad_{false}; + // Mateixa idea per al botó d'atac: edge-detection independent + // teclat/mando, perquè mantenir la tecla no genere atacs en cadena. + bool attack_held_keyboard_{false}; + bool attack_held_gamepad_{false}; + + // Temps que queda dins de l'estat ATTACKING (swing + cooldown). Quan + // arriba a 0, transició a GROUNDED. Mentre dura, el jugador queda + // bloquejat i no rep input direccional ni de salt ni d'atac. + float attacking_remaining_ms_{0.0F}; + // Llig el botó de salt aquest frame (teclat + mando assignat) i, // amb edge-detection, retorna true si s'ha de despegar el salt. [[nodiscard]] auto consumeJumpEdge() -> bool; + // Anàleg per a l'atac: edge-detection del botó d'atac (teclat + // `controls_.attack` + `SDL_GAMEPAD_BUTTON_WEST`). + [[nodiscard]] auto consumeAttackEdge() -> bool; + // Inicia un salt: copia `intended_v*` com a velocitat fixada del salt, // posa `jump_vz_ = impulse`, transiciona a AIRBORNE. void startJump(); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 658e885..f6c77ac 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. @@ -321,6 +335,7 @@ GameScene::GameScene(Config config) .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, .jump = Defaults::KeysGame::JUMP, + .attack = Defaults::KeysGame::ATTACK, }; const Player::Controls CONTROLS_P2{ .up = Defaults::KeysGameP2::UP, @@ -328,6 +343,7 @@ GameScene::GameScene(Config config) .left = Defaults::KeysGameP2::LEFT, .right = Defaults::KeysGameP2::RIGHT, .jump = Defaults::KeysGameP2::JUMP, + .attack = Defaults::KeysGameP2::ATTACK, }; // Spawn lligat a la porta: tots els jugadors apareixen davant del // passadís de sortida. `computeSpawn` deriva les coordenades de @@ -877,6 +893,26 @@ 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.owner_box, + payload.range_px); + whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey())); + // Donem accés a Hazards i jugadors per a la fase de dany. Sense això + // el whip queda merament visual. + whip->setWorldContext(&entity_manager_, &players_view_); + 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_; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 739056b..7f03f7f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -78,6 +78,7 @@ namespace Defaults { constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; constexpr SDL_Scancode JUMP = SDL_SCANCODE_M; + constexpr SDL_Scancode ATTACK = SDL_SCANCODE_N; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; } // namespace KeysGame @@ -91,6 +92,7 @@ namespace Defaults { constexpr SDL_Scancode LEFT = SDL_SCANCODE_A; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; constexpr SDL_Scancode JUMP = SDL_SCANCODE_V; + constexpr SDL_Scancode ATTACK = SDL_SCANCODE_C; } // namespace KeysGameP2 namespace Player { diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index ff6e938..923f4c3 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -431,6 +431,77 @@ namespace { REQUIRE(mummy.getDirX() <= 0.0F); } + void testMummyHurtState() { + std::cout << "[T15] mummy enterHurt → fugida + recuperació\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + fkyaml::node tmpl = EntityLoader::loadTemplate("mummy"); + Mummy mummy(tmpl); + mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + // Mateixa col·locació que testMummyStep: corredor exterior + // superior, lliure de sarcs, prou marge per fugir cap a la dreta. + mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + + // 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); + REQUIRE(mummy.getState() == Mummy::State::HURT); + REQUIRE(mummy.getDirX() > 0.0F); + + const auto BEFORE = mummy.getPixelRect(); + mummy.update(0.05F); // 50 ms: hauria d'avançar + const auto AFTER = mummy.getPixelRect(); + REQUIRE(AFTER.x > BEFORE.x); + REQUIRE(mummy.getState() == Mummy::State::HURT); + + // Avancem fins esgotar el timer (`hurt.duration_ms` = 600 ms al + // YAML → 0.6 s; sumem 1 s amb un sol step gran per garantir-ho). + mummy.update(1.0F); + REQUIRE(mummy.getState() == Mummy::State::ROAMING); + } + + void testMummyHurtAgainstWall() { + std::cout << "[T16] mummy HURT contra mur → surt abans del timer\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + fkyaml::node tmpl = EntityLoader::loadTemplate("mummy"); + Mummy mummy(tmpl); + mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + + // Posicionem la mòmia tocant la paret dreta del playfield i la + // fem fugir cap a la dreta: el primer pas hauria de topar amb + // la paret i fer-la sortir de HURT. + const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); + mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + + // La direcció preferida (RIGHT) està bloquejada al test + // d'`is_open`; entrarem amb una perpendicular o quieta. + // L'objecte de la prova és que `state_` torne a ROAMING quan + // el moviment topa: ho forcem fent que `enterHurt` triï igual + // RIGHT i topem en cridar `update`. Per aconseguir-ho posicionem + // la mòmia al límit i forcem la direcció via `setDirection` + // ABANS d'`enterHurt`. Però com `enterHurt` reescriu la + // direcció, el camí més directe és: + // 1) `enterHurt(RIGHT)` → si is_open(RIGHT) val false, intenta + // perpendiculars; si totes fallen, queda quieta. + // 2) Si està quieta, el HURT tampoc avança i només mor pel + // 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); + REQUIRE(mummy.getState() == Mummy::State::HURT); + // Update llarg → expira el timer i torna a ROAMING. + mummy.update(1.0F); + REQUIRE(mummy.getState() == Mummy::State::ROAMING); + } + + void testMummyWhippableFlag() { + std::cout << "[T17] mummy YAML llig `whippable` correctament\n"; + fkyaml::node tmpl = EntityLoader::loadTemplate("mummy"); + Mummy mummy(tmpl); + REQUIRE(mummy.isWhippable()); + } + void testTorchAnimation() { std::cout << "[T10] torch animation wired from yaml\n"; // Regressió: el torch.yaml declarava `frames: [0]` i el render no @@ -510,6 +581,9 @@ auto main() -> int { testSarcophagusYaml(); testCollisionBox(); testMummyStep(); + testMummyHurtState(); + testMummyHurtAgainstWall(); + testMummyWhippableFlag(); testTorchAnimation(); testExitedAggregator(); diff --git a/tests/test_whip_attack.cpp b/tests/test_whip_attack.cpp new file mode 100644 index 0000000..9b25b3d --- /dev/null +++ b/tests/test_whip_attack.cpp @@ -0,0 +1,302 @@ +// Smoke tests del WhipAttack: hitbox per direcció, retall contra murs i +// sarcs, aplicació de dany a mòmies whippable, alreadyHit. Friendly +// fire (Player↔Player) NO es cobreix aquí — Player viu enllaçat amb +// stubs sense estat real (veure whip_attack_player_stubs.cpp). +// +// Convencions iguals a test_entities_smoke: framework-less, REQUIRE +// macro, comptador `g_failures`. Execució: `./test_whip_attack` des de +// build/, working_dir = arrel del repo. +#include + +#include +#include +#include +#include + +#include "game/entities/concrete/mummy.hpp" +#include "game/entities/concrete/whip_attack.hpp" +#include "game/entities/entity_loader.hpp" +#include "game/entities/entity_manager.hpp" +#include "game/map/map.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__) + + // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y). + // El damage_rect del whip s'alinearà al feet box (els últims 16 px), + // i el draw_rect respectarà els offsets de la taula del disseny. + auto playerBox(float x, float y) -> SDL_FRect { + return SDL_FRect{.x = x, .y = y, .w = 16.0F, .h = 32.0F}; + } + + // Spawneja un WhipAttack ben configurat per al test, sense surface. + // Els tests no exerciten render, així que `surface_=nullptr` és OK. + auto makeWhip(WhipAttack::Direction dir, const SDL_FRect& owner_box, float range_px) + -> std::unique_ptr { + auto yaml = EntityLoader::loadTemplate("whip_attack"); + auto whip = std::make_unique(yaml); + whip->configure(dir, owner_box, range_px); + return whip; + } + + void testWhipDrawRectPerDirection() { + std::cout << "[W1] WhipAttack draw_rect per direcció\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Owner ben dins del playfield perquè el draw_rect (no es retalla) + // tinga la mida nominal completa. + // Posicionem el jugador amb la collision (bottom 16×16) al + // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la + // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect + // cap al sarc-buit corredor i no es retalla per sarcs. + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + const float RANGE = 48.0F; + + const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); + REQUIRE(WE->getPixelRect().w == 80.0F); + REQUIRE(WE->getPixelRect().h == 32.0F); + + const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE); + REQUIRE(WW->getPixelRect().w == 80.0F); + REQUIRE(WW->getPixelRect().h == 32.0F); + + const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE); + REQUIRE(WN->getPixelRect().w == 16.0F); + REQUIRE(WN->getPixelRect().h == 96.0F); + + const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); + REQUIRE(WS->getPixelRect().w == 16.0F); + REQUIRE(WS->getPixelRect().h == 96.0F); + } + + void testWhipDrawRectOffsets() { + std::cout << "[W7] offsets del draw_rect per direcció (taula del disseny)\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Posicionem el jugador amb la collision (bottom 16×16) al + // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la + // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect + // cap al sarc-buit corredor i no es retalla per sarcs. + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + const float RANGE = 48.0F; + + // E: (player.x - 16, player.y), 80×32. + const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); + REQUIRE(WE->getPixelRect().x == OX - 16.0F); + REQUIRE(WE->getPixelRect().y == OY); + + // W: (player.x - 48, player.y), 80×32. + const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE); + REQUIRE(WW->getPixelRect().x == OX - 48.0F); + REQUIRE(WW->getPixelRect().y == OY); + + // N: (player.x, player.y - 48), 16×96. + const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE); + REQUIRE(WN->getPixelRect().x == OX); + REQUIRE(WN->getPixelRect().y == OY - 48.0F); + + // S: (player.x, player.y - 16), 16×96. + const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); + REQUIRE(WS->getPixelRect().x == OX); + REQUIRE(WS->getPixelRect().y == OY - 16.0F); + } + + void testWhipFeetYDelegation() { + std::cout << "[W8] feet_y delegat a la base de l'owner_box\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Posicionem el jugador amb la collision (bottom 16×16) al + // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la + // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect + // cap al sarc-buit corredor i no es retalla per sarcs. + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + const float EXPECTED_FEET_Y = OY + 32.0F; // base inferior del 16×32 + + // Independent de la direcció: el feet_y és el del jugador. + for (auto dir : {WhipAttack::Direction::EAST, + WhipAttack::Direction::WEST, + WhipAttack::Direction::NORTH, + WhipAttack::Direction::SOUTH}) { + const auto W = makeWhip(dir, BOX, 48.0F); + REQUIRE(W->getFeetY() == EXPECTED_FEET_Y); + } + } + + void testWhipHitboxClippedByWall() { + std::cout << "[W2] damage_rect retallat per la paret del playfield\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Llancem un E des d'una posició a 10 px de la paret dreta del + // playfield, amb un range de 48 px → la hitbox efectiva ha de + // quedar acotada a < 48 i no més enllà del mur. + const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); + const float OX = RIGHT_WALL - 16.0F - 10.0F; // l'owner mateix dins + const float OY = static_cast(Map::playfield_y + 96); + const auto BOX = playerBox(OX, OY); + const float RANGE = 48.0F; + + const auto W = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); + const auto DMG = W->getDamageRect(); + REQUIRE(DMG.w < RANGE); + REQUIRE(DMG.x + DMG.w <= RIGHT_WALL); + } + + void testWhipHitboxFreeAtFullRange() { + std::cout << "[W3] damage_rect sense bloquejador → longitud completa\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Owner ben dins del playfield, range curt → 16 px lliures fins + // a topar amb res. La longitud efectiva = RANGE. + const float OX = static_cast(Map::playfield_x + 32); + const float OY = static_cast(Map::playfield_y + 96); + const auto BOX = playerBox(OX, OY); + const float RANGE = 16.0F; + + const auto W = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); + const auto DMG = W->getDamageRect(); + REQUIRE(DMG.h == RANGE); + REQUIRE(DMG.w == 16.0F); + } + + void testWhipHitsMummyInRange() { + std::cout << "[W4] mòmia en rang entra a HURT\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + EntityManager manager; + auto mummy_yaml = EntityLoader::loadTemplate("mummy"); + auto mummy_owned = std::make_unique(mummy_yaml); + mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + + // Owner i mòmia al mateix corredor; mòmia 24 px a la dreta del + // jugador → dins del damage_rect (48 px de rang). + // Posicionem el jugador amb la collision (bottom 16×16) al + // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la + // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect + // cap al sarc-buit corredor i no es retalla per sarcs. + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + // Posicionem la mòmia perquè la seua collision box solape amb + // la zona on cau el damage_rect (els últims 16 px verticals + // del player, és a dir y ∈ [OY+16, OY+32]). + // Volem que la collision (offset_y=12, height=12) solape amb el + // damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y + // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. + mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); + whip->setWorldContext(&manager, nullptr); + + // 60 ms → frame 1 (active). active_frames: [1, 2]. + whip->update(0.06F); + + REQUIRE(mummy_raw->getState() == Mummy::State::HURT); + } + + void testWhipNoHitOutOfRange() { + std::cout << "[W5] mòmia fora del rang no rep dany\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + EntityManager manager; + auto mummy_yaml = EntityLoader::loadTemplate("mummy"); + auto mummy_owned = std::make_unique(mummy_yaml); + mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + + // Mateixa col·locació corredor 2-3 que W4: el damage_rect cap + // íntegre al sarc-buit; només l'eix x distingirà "dins" de "fora". + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + // Mòmia 96 px a la dreta de l'owner → fora d'un range de 32. + // Vertically aligned with the damage band perquè el test només + // depenga de l'eix horitzontal (no d'una falla d'alineament Y). + mummy_owned->setPixelRect({.x = OX + 96.0F, .y = OY, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 32.0F); + whip->setWorldContext(&manager, nullptr); + whip->update(0.06F); + + REQUIRE(mummy_raw->getState() == Mummy::State::ROAMING); + } + + void testWhipSingleHitPerSwing() { + std::cout << "[W6] una mòmia rep un sol impacte per swing\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + EntityManager manager; + auto mummy_yaml = EntityLoader::loadTemplate("mummy"); + auto mummy_owned = std::make_unique(mummy_yaml); + mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + + // Posicionem el jugador amb la collision (bottom 16×16) al + // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la + // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect + // cap al sarc-buit corredor i no es retalla per sarcs. + const float OX = static_cast(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); + // Volem que la collision (offset_y=12, height=12) solape amb el + // damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y + // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. + mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); + whip->setWorldContext(&manager, nullptr); + + // Primera passada (frame 1, actiu) → mòmia a HURT. + whip->update(0.06F); + REQUIRE(mummy_raw->getState() == Mummy::State::HURT); + const float HURT_INITIAL = mummy_raw->getHurtRemainingS(); + + // Avancem la mòmia (descompta el seu timer) i tornem a tickar el + // whip mentre l'animació segueix activa: si `already_hit_` + // funciona, el segon hit es bloqueja i el timer de la mòmia NO + // es reseteja al valor inicial. + mummy_raw->update(0.05F); + const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS(); + REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL); + + whip->update(0.05F); // frame 2, encara actiu + REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); + } + +} // namespace + +auto main() -> int { + std::cout << "== smoke tests: WhipAttack ==\n"; + + testWhipDrawRectPerDirection(); + testWhipDrawRectOffsets(); + testWhipFeetYDelegation(); + testWhipHitboxClippedByWall(); + testWhipHitboxFreeAtFullRange(); + testWhipHitsMummyInRange(); + testWhipNoHitOutOfRange(); + testWhipSingleHitPerSwing(); + + if (g_failures == 0) { + std::cout << "ALL OK\n"; + return EXIT_SUCCESS; + } + std::cerr << g_failures << " failures\n"; + return EXIT_FAILURE; +} diff --git a/tests/whip_attack_player_stubs.cpp b/tests/whip_attack_player_stubs.cpp new file mode 100644 index 0000000..cb116a4 --- /dev/null +++ b/tests/whip_attack_player_stubs.cpp @@ -0,0 +1,21 @@ +// Stubs de Player per al binari `test_whip_attack`. +// +// El path de friendly fire del `WhipAttack` referencia +// `Player::enterHurt(Cardinal)` i `Player::getBaseBox() const`. Linkar +// `player.cpp` arrossegaria tot l'stack del jugador (audio, gamepad, +// input, screen, resource_cache…), una explosió de dependències per a +// uns tests que no exerciten aqueix camí. Aquests stubs proporcionen +// els símbols com a no-op perquè el test del whip pugui linkar i centrar +// la cobertura en hitbox + Mummy. +// +// Si algun dia volem cobrir friendly fire, caldrà o bé un binari de test +// amb tot l'stack o invertir la dependència via callable injectable +// (refactor de `WhipAttack::applyDamage`). +#include "game/entities/player.hpp" + +void Player::enterHurt(Cardinal /*preferred*/) { +} + +auto Player::getBaseBox() const -> SDL_FRect { + return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F}; +}