From df60709e6d738b44df63db4c2a410c12202536d7 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Mon, 27 Apr 2026 17:32:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(whip):=20aplicaci=C3=B3=20de=20dany=20amb?= =?UTF-8?q?=20retall=20de=20hitbox,=20alreadyHit=20i=20friendly=20fire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 13 +- source/game/entities/concrete/whip_attack.cpp | 152 ++++++++++++++++++ source/game/entities/concrete/whip_attack.hpp | 47 +++++- source/game/entities/player.hpp | 8 + source/game/scenes/game_scene.cpp | 3 + 5 files changed, 217 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 41f5b7e..37febf7 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 @@ -48,6 +51,12 @@ set(ENTITY_SOURCES source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp 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 ) @@ -339,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" diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp index 1f654f7..69e49bb 100644 --- a/source/game/entities/concrete/whip_attack.cpp +++ b/source/game/entities/concrete/whip_attack.cpp @@ -5,7 +5,11 @@ #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 { @@ -92,9 +96,73 @@ void WhipAttack::configure(Direction dir, float spawn_x, float spawn_y, float ra const auto& chosen = spriteFor(dir); setPixelRect({.x = spawn_x, .y = spawn_y, .w = chosen.frame_w, .h = chosen.frame_h}); + computeDamageRect(dir, spawn_x, spawn_y, range_px); + if (anim_.has_value()) { anim_->reset(); } } +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; } @@ -115,9 +183,93 @@ auto WhipAttack::spriteFor(Direction dir) const -> const DirSprite& { 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(); diff --git a/source/game/entities/concrete/whip_attack.hpp b/source/game/entities/concrete/whip_attack.hpp index 3a03105..ce975f9 100644 --- a/source/game/entities/concrete/whip_attack.hpp +++ b/source/game/entities/concrete/whip_attack.hpp @@ -1,8 +1,10 @@ #pragma once +#include + #include -#include #include +#include #include #include "external/fkyaml_node.hpp" @@ -10,6 +12,8 @@ #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. @@ -51,10 +55,20 @@ class WhipAttack : public Attack { // 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). + // hitbox a (spawn_x, spawn_y). També calcula la `damage_rect_` + // retallada contra murs i sarcs (mai sortiria del playfield ni + // travessaria un sarc). void configure(Direction dir, float spawn_x, float spawn_y, float range_px); - // Avança animació; quan completa, marca dead. + // 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_; } @@ -71,6 +85,11 @@ class WhipAttack : public Attack { // 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_; } + // 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ó @@ -81,11 +100,31 @@ class WhipAttack : public Attack { 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_; - std::set already_hit_; + // 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}; + + EntityManager* entity_manager_{nullptr}; + const std::vector* players_{nullptr}; DirSprite sprite_north_; DirSprite sprite_south_; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 773d2bb..ad2ad3e 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -441,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. @@ -554,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. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index e9f9129..b718fed 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -908,6 +908,9 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) { payload.spawn_y, 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)); }