From 4da50571eb86eaffad720c4e149e49b30872cac9 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 13:32:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(hazard):=20nou=20hazard=20Hole=20=E2=80=94?= =?UTF-8?q?=20caiguda=20per=20forat=20amb=20clip=20rect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 3 + data/entities/hole/hole.gif | Bin 0 -> 182 bytes data/entities/hole/hole.yaml | 26 ++++ data/maps/p1/r1.yaml | 6 + data/player/morcus.yaml | 10 ++ data/player/sam.yaml | 10 ++ source/game/entities/concrete/floor_trap.cpp | 4 + source/game/entities/concrete/hole.cpp | 51 +++++++ source/game/entities/concrete/hole.hpp | 34 +++++ source/game/entities/concrete/wall_trap.cpp | 3 + source/game/entities/entity.cpp | 7 + source/game/entities/entity.hpp | 23 ++++ .../entities/entity_manager_loadlevel.cpp | 11 +- source/game/entities/player.cpp | 128 ++++++++++++++++-- source/game/entities/player.hpp | 52 ++++++- tests/hole_player_stubs.cpp | 18 +++ tests/test_entities_smoke.cpp | 5 +- 17 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 data/entities/hole/hole.gif create mode 100644 data/entities/hole/hole.yaml create mode 100644 source/game/entities/concrete/hole.cpp create mode 100644 source/game/entities/concrete/hole.hpp create mode 100644 tests/hole_player_stubs.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 68f68d6..84f3ebb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ set(ENTITY_SOURCES_BASE source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp + source/game/entities/concrete/hole.cpp source/game/entities/concrete/dust.cpp ) @@ -349,6 +350,7 @@ option(BUILD_TESTS "Build smoke tests" ON) if(BUILD_TESTS) add_executable(test_entities_smoke tests/test_entities_smoke.cpp + tests/hole_player_stubs.cpp source/core/resources/resource_helper.cpp source/core/resources/resource_pack.cpp source/game/map/map.cpp @@ -372,6 +374,7 @@ if(BUILD_TESTS) add_executable(test_whip_attack tests/test_whip_attack.cpp tests/whip_attack_player_stubs.cpp + tests/hole_player_stubs.cpp source/core/resources/resource_helper.cpp source/core/resources/resource_pack.cpp source/game/map/map.cpp diff --git a/data/entities/hole/hole.gif b/data/entities/hole/hole.gif new file mode 100644 index 0000000000000000000000000000000000000000..9805e8a79a32286f92009407772555fbe311690c GIT binary patch literal 182 zcmZ?wbh9u|6krfwIKlt|l44>CGBRq4irPv_+Nw&rDk=sVs)pL?_WEkZ+8Sp1+7|jc z_U6Vujy66{wrSqBk-qLlAujc4k@b0LGh3<}BI0IM)<+iASM|>Xn} zL_u~kuo^E=WJ height`). El + # bucle de contacte admet l'entitat per `flags.triggers_on_overlap`, + # encara que `flags.collides` sigui false. + size: [16, 16] + height: 0 +flags: + collides: false + interactable: false + # Avui el loader no llig el bloc `flags:` (és declaratiu); el flag + # real es fixa al constructor C++ de `Hole`. Mantenim el camp per + # coherència amb la resta de plantilles i per quan es cablege la + # lectura genèrica de flags. + triggers_on_overlap: true +animations: + idle: { frames: [0], fps: 1, loop: true } +stats: + damage: 0 diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index eb123f1..3555088 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -138,3 +138,9 @@ entities: position: [10, 7] - type: mummy position: [7, 9] + # Forat de prova: tile de sòl pla a la galeria superior, 2 tiles a + # l'est del punt d'aterratge del jugador (door_col=10). Lluny dels + # sarcs i de les momies; accessible caminant cap a l'est tot just + # després del spawn. + - type: hole + position: [12, 3] diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index aedb8e1..53fd579 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -73,6 +73,16 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false + # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada + # **placeholders** — pendents de Sergio: idealment l'índex de la + # casella buida del spritesheet i una durada curta. `loop: false` + # com `death_spinning` perquè `animationIsCompleted()` dispari el + # respawn timer determinísticament. + death_hole: + frames: [15] + frame_ms: 60 + loop: false + # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). inertia: diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 867899f..5fad03a 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -73,6 +73,16 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false + # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada + # **placeholders** — pendents de Sergio: idealment l'índex de la + # casella buida del spritesheet i una durada curta. `loop: false` + # com `death_spinning` perquè `animationIsCompleted()` dispari el + # respawn timer determinísticament. + death_hole: + frames: [15] + frame_ms: 60 + loop: false + # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). inertia: diff --git a/source/game/entities/concrete/floor_trap.cpp b/source/game/entities/concrete/floor_trap.cpp index fa451f6..501430a 100644 --- a/source/game/entities/concrete/floor_trap.cpp +++ b/source/game/entities/concrete/floor_trap.cpp @@ -10,6 +10,10 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("floor_trap"); collides_ = false; interactable_ = false; + // Trigger d'àrea: la trampa no bloqueja el pas però vol rebre + // `onPlayerContact` quan el jugador la solapa. Tanca el gap latent + // que la deixava muda (`checkPlayerCollisions` filtrava pel `collides`). + triggers_on_overlap_ = true; if (yaml.contains("cycle")) { const auto& c = yaml["cycle"]; diff --git a/source/game/entities/concrete/hole.cpp b/source/game/entities/concrete/hole.cpp new file mode 100644 index 0000000..4d3092c --- /dev/null +++ b/source/game/entities/concrete/hole.cpp @@ -0,0 +1,51 @@ +#include "game/entities/concrete/hole.hpp" + +#include + +#include + +#include "game/entities/entity_factory.hpp" +#include "game/entities/player.hpp" + +Hole::Hole(const fkyaml::node& yaml) { + name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("hole"); + // Sense xoc físic ni interacció — només trigger d'àrea. El bucle de + // contacte ens admet via `triggers_on_overlap_=true`. Damage queda a + // 0 (no publiquem `player_hit`); la mort la dispara `integrateJump` + // del jugador quan supera `HOLE_FALL_DEPTH`. + collides_ = false; + interactable_ = false; + triggers_on_overlap_ = true; + // Decal de sòl: `Entity::getFeetY()` torna sentinel mínim si aquest + // flag està actiu — així el forat es pinta abans que qualsevol actor. + is_floor_decal_ = true; + setDamage(0); +} + +void Hole::onPlayerContact(Player* player) { + if (player == nullptr) { return; } + // Només mossega quan el jugador trepitja el forat de peu (GROUNDED). + // En l'aire, el filtre d'altura del bucle ja l'ha rebutjat (height_=0 + // i jump_z_>0 prou per superar-ho); però mantenim el gat per defensa + // davant de futurs canvis al filtre. + if (player->getState() != Player::State::GROUNDED) { return; } + + // Re-verificació estricta amb la hit-box interna del jugador (8×8): + // així el contacte només es dispara quan el centre del jugador ha + // entrat al tile del forat, no quan només el frega per la vora. + const SDL_FRect HIT = player->getHitBox(); + const SDL_FRect BOX = getCollisionRect(); + const bool OVERLAP = HIT.x + HIT.w > BOX.x && BOX.x + BOX.w > HIT.x && HIT.y + HIT.h > BOX.y && BOX.y + BOX.h > HIT.y; + if (!OVERLAP) { return; } + + // Snap al centre XY del forat + Y de la base del tile (per al clip + // rect del sprite mentre cau). `enterHoleFall` rep coordenades + // absolutes en el canvas — el player s'encarrega de convertir a la + // seua convenció (cantó superior-esquerra del sprite). + const float CENTER_X = BOX.x + (BOX.w * 0.5F); + const float CENTER_Y = BOX.y + (BOX.h * 0.5F); + const float FLOOR_Y = BOX.y + BOX.h; + player->enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y); +} + +REGISTER_ENTITY("hole", Hole) diff --git a/source/game/entities/concrete/hole.hpp b/source/game/entities/concrete/hole.hpp new file mode 100644 index 0000000..3f05bb3 --- /dev/null +++ b/source/game/entities/concrete/hole.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "external/fkyaml_node.hpp" +#include "game/entities/hazard.hpp" + +// Forat al sòl: hazard estàtic de 16×16 que NO bloqueja el pas però +// detecta quan el jugador hi cau a sobre. Característiques: +// - `collides_=false` + `triggers_on_overlap_=true`: rep `onPlayerContact` +// pel bucle de `EntityManager::checkPlayerCollisions` sense bloquejar +// el moviment del jugador. +// - `height_=0`: el filtre de col·lisió per altura existent deixa que +// un jugador AIRBORNE amb `jump_z_>0` el creui sense disparar res +// (saltar per damunt d'un forat). +// - Damage 0: el contacte no publica `player_hit`. En lloc d'això, +// `onPlayerContact` invoca `Player::enterHoleFall`, que transiciona +// el jugador a AIRBORNE amb subestat `falling_through_hole_`. +// - Verificació amb hit-box interna: `onPlayerContact` re-verifica +// l'overlap amb `Player::getHitBox()` (8×8 centrada al sprite), +// així només es dispara quan el centre del jugador entra al tile. +// - Z-order: `is_floor_decal_=true` (heretat) — `Entity::getFeetY()` +// retorna sentinel mínim, així el forat es pinta abans que qualsevol +// actor i les momies passen per davant. L'efecte "el sòl es traga el +// jugador" no es fa amb Y-sort: el `Player::render` aplica un clip +// rect mentre `falling_through_hole_=true` que retalla el sprite per +// sota de la base del tile del forat. +class Hole : public StaticHazard { + public: + explicit Hole(const fkyaml::node& yaml); + + // No publica `player_hit`. En el seu lloc, dispara + // `Player::enterHoleFall(center_x, center_y, floor_y)` perquè el + // jugador armi el clip rect a la Y de la base del forat. + void onPlayerContact(Player* player) override; +}; diff --git a/source/game/entities/concrete/wall_trap.cpp b/source/game/entities/concrete/wall_trap.cpp index 5b385f4..1b14e47 100644 --- a/source/game/entities/concrete/wall_trap.cpp +++ b/source/game/entities/concrete/wall_trap.cpp @@ -10,6 +10,9 @@ WallTrap::WallTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("wall_trap"); collides_ = false; interactable_ = false; + // Trigger d'àrea: igual que FloorTrap. Tanca el gap latent del filtre + // de `checkPlayerCollisions` que ho deixava mut. + triggers_on_overlap_ = true; if (yaml.contains("cycle")) { const auto& c = yaml["cycle"]; diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index fb0e3e9..092451a 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -1,5 +1,6 @@ #include "game/entities/entity.hpp" +#include #include Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect) @@ -38,6 +39,12 @@ auto Entity::getCollisionRect() const -> SDL_FRect { } auto Entity::getFeetY() const -> float { + // Decal de sòl: sentinel mínim per quedar al principi del Y-sort + // ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de + // qualsevol actor. Sense passades de render extra. + if (is_floor_decal_) { + return std::numeric_limits::lowest(); + } const auto RECT = getCollisionRect(); return RECT.y + RECT.h; } diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index fb44080..7f2c937 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -88,6 +88,27 @@ class Entity { [[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; } + // Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`) + // però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat + // per a hazards "no físics" (forats al sòl, picos retràctils, plaques + // de pressió). El bucle de contacte (`checkPlayerCollisions`) admet + // l'entitat si compleix `collides_ || triggers_on_overlap_`. El filtre + // d'altura per salt (`height_`) i la caixa de col·lisió funcionen igual. + [[nodiscard]] auto triggersOnOverlap() const -> bool { return triggers_on_overlap_; } + void setTriggersOnOverlap(bool v) { triggers_on_overlap_ = v; } + + // Decal de sòl: hazards estàtics sense presència vertical (forats, + // plaques de pressió, charcos). Es pinten ABANS de qualsevol actor + // dins del Y-sort de `GameScene` perquè conceptualment són "pintura + // sobre el tile". Implementació: `getFeetY()` retorna un sentinel + // mínim (`std::numeric_limits::lowest()`) per quedar al + // principi del `stable_sort` ascendent — així no calen passades de + // render extra. Les subclasses (p.ex. `Hole`) poden sobreescriure + // `getFeetY` per saltar-se el sentinel en moments concrets (p.ex. + // un forat que està tragant un jugador i ha de pintar-se al damunt). + [[nodiscard]] auto isFloorDecal() const -> bool { return is_floor_decal_; } + void setFloorDecal(bool v) { is_floor_decal_ = v; } + // Marca per a entitats transitòries: efectes (polvet, espurnes, // explosions...) que viuen una sola animació i s'autoeliminen quan // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` @@ -172,6 +193,8 @@ class Entity { std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; + bool triggers_on_overlap_{false}; + bool is_floor_decal_{false}; int height_{0}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 99dee18..7d25c40 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -61,7 +61,12 @@ void EntityManager::renderAll() { void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } for (const auto& entity : entities_) { - if (!entity->collides()) { continue; } + // Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no + // bloquegen però volen rebre `onPlayerContact`). Tanquem el gap + // latent de FloorTrap/WallTrap (que tenien `collides_=false` i + // mai no es disparaven) i obrim la porta a hazards no-físics + // com `Hole`. + if (!entity->collides() && !entity->triggersOnOverlap()) { continue; } const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { @@ -69,6 +74,10 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { // contacte amb hazards (un segon toc de mòmia mentre la roda // ja és no-op; cap altra entitat l'ha de fer transicionar). if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; } + // Mentre el jugador cau per un forat, ignorem qualsevol altre + // hazard: la transició a `enterDying()` ja la dispara + // `integrateJump` quan supera `HOLE_FALL_DEPTH`. + if (p->isFallingThroughHole()) { continue; } // Filtre per altura: si el jugador està en l'aire i està per // damunt de l'alçada del cos de l'entitat, no hi ha contacte. // És el mecanisme del salt per esquivar momies, roques, etc. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 1187d49..2a673b5 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -12,6 +12,7 @@ #include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" +#include "core/rendering/canvas.hpp" #include "core/rendering/screen.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" @@ -910,6 +911,17 @@ auto Player::integrateJump(float delta_time) -> bool { if (state_ != State::AIRBORNE) { return false; } jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; jump_z_ += jump_vz_ * delta_time; + // Caiguda per agujero: el sòl ja no és present sota el jugador, així + // que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment + // el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`, + // disparem la mort. La cinemàtica de respawn (`enterDying → + // spawnFromAir`) reseteja el flag i la posició al spawn point. + if (falling_through_hole_) { + if (jump_z_ <= -HOLE_FALL_DEPTH) { + enterDying(DeathCause::HOLE); + } + return false; + } if (jump_z_ <= 0.0F) { jump_z_ = 0.0F; jump_vz_ = 0.0F; @@ -1075,7 +1087,7 @@ void Player::applyMovement(float delta_time) { if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } } -void Player::enterDying() { +void Player::enterDying(DeathCause cause) { // Idempotent. Un segon contacte de momia mentre el jugador ja està // en DYING no reinicia l'animació ni el comptador. Tampoc disparem // el cicle si el jugador està fora de joc (EXITED al passar la @@ -1091,6 +1103,8 @@ void Player::enterDying() { being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; + falling_through_hole_ = false; + hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; @@ -1102,7 +1116,18 @@ void Player::enterDying() { death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; - setCurrentAnimation("death_spinning"); + // Presentació segons la causa. Cada `DeathCause` té la seua animació + // dedicada al YAML del player; `applyDyingMovement` arma el respawn + // timer quan `animationIsCompleted()` flanqueja, igual per a totes. + switch (cause) { + case DeathCause::HOLE: + setCurrentAnimation("death_hole"); + break; + case DeathCause::GENERIC: + default: + setCurrentAnimation("death_spinning"); + break; + } // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // `readHurtBlock`). El bloc `death:` no té camp de so propi — @@ -1204,19 +1229,46 @@ void Player::render() { if (state_ == State::DYING && death_animation_complete_) { return; } // Mentre el jugador està enlaire, el sprite es desplaça visualment - // cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera + // `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és + // negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius + // fins a `-HOLE_FALL_DEPTH`). Modifiquem `pos_.y` (la posició entera // de render) abans de cridar el render base, i la restaurem després // perquè l'estat seguent del frame ho recalcule des de `y_` (la font // de veritat float). El sòl sota el jugador no es pinta des d'ací: - // això ho fa el GameScene en una passada de sombres a banda. - if (jump_z_ <= 0.0F) { - AnimatedSprite::render(); - return; + // això ho fa el GameScene en una passada de sombres a banda. El cas + // `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar + // `pos_.y` innecessàriament. + // Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip + // global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter + // de `Surface` respecta `Rendering::activeClip()`); a mesura que + // `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la + // línia deixen de pintar-se. Fem el push/pop al voltant de tot el + // render del sprite per cobrir tant el cas `jump_z_==0` (caiguda + // just iniciada) com els frames posteriors. `pushClip` ja clamp + // a la mida del canvas, així que un `h` negatiu es traduiria en + // una franja buida i el sprite quedaria invisible — comportament + // correcte si el clip ja ha "tapat" tota la pantalla. + const bool CLIP_HOLE = falling_through_hole_; + if (CLIP_HOLE) { + const auto CANVAS = Rendering::activeCanvas(); + Rendering::pushClip({ + .x = 0, + .y = 0, + .w = CANVAS.width, + .h = static_cast(hole_clip_bottom_y_), + }); } - const float SAVED_Y = pos_.y; - pos_.y = SAVED_Y - jump_z_; - AnimatedSprite::render(); - pos_.y = SAVED_Y; + + if (jump_z_ == 0.0F) { + AnimatedSprite::render(); + } else { + const float SAVED_Y = pos_.y; + pos_.y = SAVED_Y - jump_z_; + AnimatedSprite::render(); + pos_.y = SAVED_Y; + } + + if (CLIP_HOLE) { Rendering::popClip(); } } void Player::renderShadow() const { @@ -1224,6 +1276,10 @@ void Player::renderShadow() const { // que poden cridar `renderShadow` per a tots els jugadors sense // discriminar. if (!isAirborne()) { return; } + // Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una + // sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins + // que `enterDying` netegi el flag. + if (falling_through_hole_) { return; } // Sombra centrada al sprite, recolzada a la línia dels peus reals // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura @@ -1359,6 +1415,8 @@ void Player::respawn(float x, float y) { being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; + falling_through_hole_ = false; + hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; @@ -1396,6 +1454,54 @@ void Player::disable() { last_input_cardinal_ = Cardinal::NONE; } +void Player::enterHoleFall(float center_x, float center_y, float floor_y) { + // Defensa: només transicionem des de GROUNDED. Saltadors, AIRBORNE, + // HURT, DYING, etc. no han de caure pel forat (entre altres coses, + // perquè `checkPlayerCollisions` ja filtra airborne via altura, però + // afegim el gat ací per coherència amb la resta de transicions). + if (state_ != State::GROUNDED) { return; } + + // Snap del centre de la hit-box a la coordenada central del forat. + // Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix: + // així el centre exacte de la caja de contacte queda alineat amb + // (center_x, center_y), independentment de la geometria interna del + // sprite. `setPosX/Y` és l'API canònica per teleportar el jugador + // (la usen `respawn` i `spawnFromAir`). + const float HIT_HALF = static_cast(hit_size) * 0.5F; + setPosX(center_x - static_cast(hit_x_offset) - HIT_HALF); + setPosY(center_y - static_cast(hit_y_offset) - HIT_HALF); + + // Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al + // centre del forat. + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + input_vx_ = 0.0F; + input_vy_ = 0.0F; + motion_time_ms_ = 0.0F; + being_pushed_ = false; + setVelX(0.0F); + setVelY(0.0F); + + jump_z_ = 0.0F; + jump_vz_ = 0.0F; + state_ = State::AIRBORNE; + falling_through_hole_ = true; + // Línia visual on el clip rect ha de retallar el sprite. Restem + // `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels + // transparents — el retall ha de coincidir amb la silueta visible, + // no amb la vora del rect del sprite. + hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET; + + // Animació de caiguda: respectem el `facing_` que ja portava el jugador + // (no té sentit reorientar-lo cap al sud només perquè caigui en un + // forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el + // prefix `jump_` i hi afegeix el sufix cardinal correcte — + // `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el + // facing actual. Si demà cal una animació de "cara de pànic", només + // cal canviar aquí pel `setCurrentAnimation` corresponent. + setCurrentAnimation(animationName(false)); +} + void Player::spawnFromAir(float x, float y) { // Net total: equival a un `respawn` seguit d'una entrada en l'aire. // Així el flag de gir, l'animació, els flancs del salt i les diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index d17ca82..b564463 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -40,6 +40,16 @@ class Player : public AnimatedSprite { // que decau per inèrcia fins a parar (~2 tiles). // No col·lisiona amb cap altre jugador. Quan // s'atura, torna a GROUNDED. + // Causa de la mort actual. La passa el caller a `enterDying(cause)` per + // triar la presentació visual (`death_` al YAML del player). El + // default `GENERIC` cobreix tots els callsites antics (mòmies, etc.) + // sense haver de tocar-los; els nous (caiguda per `Hole`, en el futur + // pinchos / timeout / etc.) passen la seua causa explícita. + enum class DeathCause : std::uint8_t { + GENERIC, // mort estàndard — animació `death_spinning` + HOLE, // caiguda per agujero — animació `death_hole` + }; + enum class State : std::uint8_t { GROUNDED, AIRBORNE, @@ -226,6 +236,21 @@ class Player : public AnimatedSprite { return state_ == State::GROUNDED || state_ == State::LANDING_STUN; } + // El jugador està caient per un agujero (`Hole`): subestat dins + // d'AIRBORNE amb gravetat aplicada SENSE clamp a 0 — `jump_z_` baixa + // a negatius fins a `HOLE_FALL_DEPTH`, llavors `enterDying()`. Mentre + // dura, `checkPlayerCollisions` salta aquest jugador (cap més hazard + // l'ha de tocar). Es neteja a `enterDying()` i a `respawn()`. + [[nodiscard]] auto isFallingThroughHole() const -> bool { return falling_through_hole_; } + + // Activa la caída per agujero: snap del jugador al centre (cx, cy) en + // coordenades absolutes del canvas, transició a AIRBORNE amb + // `jump_z_=0` i `falling_through_hole_=true`, i arma el clip rect + // visual a partir de `floor_y` (Y de la base del tile del forat, + // canvas píxels). Desactiva input direccional reusant la branca + // AIRBORNE existent. No-op si el jugador no està a GROUNDED. + void enterHoleFall(float center_x, float center_y, float floor_y); + // Despawn lògic: el jugador acaba de creuar la porta de sortida. // Idempotent — només la primera crida fa la transició; les // posteriors són no-op. La publicació del `player_exited` al bus @@ -254,7 +279,7 @@ class Player : public AnimatedSprite { // momia mentre la roda no la reinicia). Quan l'animació acaba i // `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a // GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius). - void enterDying(); + void enterDying(DeathCause cause = DeathCause::GENERIC); // Coordenades del spawn de respawn (cantó superior-esquerra del // sprite, mateixa convenció que `respawn(x, y)`). El GameScene les @@ -655,6 +680,31 @@ class Player : public AnimatedSprite { float jump_z_{0.0F}; float jump_vz_{0.0F}; + // Subestat dins d'AIRBORNE: `enterHoleFall` el posa a true; mentre dura, + // `integrateJump` no clampa `jump_z_` a 0 — deixa baixar a negatius i + // dispara `enterDying()` quan creua `HOLE_FALL_DEPTH`. Es neteja a + // `enterDying()` i `respawn()`. + bool falling_through_hole_{false}; + + // Y absoluta (canvas píxels) per sota de la qual el `Player::render` + // retalla el sprite mentre `falling_through_hole_=true`. La fixa + // `enterHoleFall` a `floor_y - SPRITE_FLOOR_OFFSET` i la neteja + // `respawn`. Sense ús quan no estem caient per un forat. + float hole_clip_bottom_y_{0.0F}; + + // Profunditat (en píxels per sota del sòl) que ha de superar el jugador + // mentre cau per un agujero abans que `integrateJump` el mate. Constant + // per ara (ajustable a posteriori si el feel no és bo). + static constexpr float HOLE_FALL_DEPTH = 20.0F; + + // Píxels transparents a la base del sprite del jugador (16×32). El + // sprite "camina pel mig del passadís en falsa perspectiva", així que + // la silueta no toca la vora inferior del rect del sprite — hi queda + // un marge transparent. Quan armem el clip rect del forat, restem + // aquest offset a la `floor_y` perquè la línia de retall coincideixi + // visualment amb la base del tile, no amb la vora del sprite. + static constexpr float SPRITE_FLOOR_OFFSET = 3.0F; + // Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és // LANDING_STUN i tot l'input queda bloquejat. float landing_stun_remaining_ms_{0.0F}; diff --git a/tests/hole_player_stubs.cpp b/tests/hole_player_stubs.cpp new file mode 100644 index 0000000..314d513 --- /dev/null +++ b/tests/hole_player_stubs.cpp @@ -0,0 +1,18 @@ +// Stubs de Player per als binaris `test_entities_smoke` i `test_whip_attack`. +// +// `Hole` referencia `Player::getHitBox()` i `Player::enterHoleFall()` dins +// de `onPlayerContact` (camí no exercit pels tests stand-alone). Linkar +// `player.cpp` arrossegaria audio/gamepad/input/screen/cache, una explosió +// de dependències que els smokes intenten evitar — mateixa decisió que +// `whip_attack_player_stubs.cpp`. Aquests stubs proporcionen els símbols +// com a no-op perquè T1/T7 puguin fer `factory.create("hole", ...)`. +#include + +#include "game/entities/player.hpp" + +auto Player::getHitBox() const -> SDL_FRect { + return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F}; +} + +void Player::enterHoleFall(float /*center_x*/, float /*center_y*/, float /*floor_y*/) { +} diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index f11190d..584f781 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -44,6 +44,7 @@ #include "game/entities/animation_component.hpp" #include "game/entities/concrete/door.hpp" #include "game/entities/concrete/floor_trap.hpp" +#include "game/entities/concrete/hole.hpp" #include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/rolling_rock.hpp" #include "game/entities/concrete/sarcophagus.hpp" @@ -74,7 +75,7 @@ namespace { const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", - "rolling_rock", "wall_trap", "floor_trap", + "rolling_rock", "wall_trap", "floor_trap", "hole", }; for (const auto& type : TYPES) { fkyaml::node tmpl = EntityLoader::loadTemplate(type); @@ -251,7 +252,7 @@ namespace { std::cout << "[T7] plantilles YAML\n"; const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", - "rolling_rock", "wall_trap", "floor_trap", + "rolling_rock", "wall_trap", "floor_trap", "hole", }; for (const auto& type : TYPES) { try {