From 9f0a566ffbe3c1bc7483704c64dd21a1b30c3658 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 29 Apr 2026 11:40:05 +0200 Subject: [PATCH] feat(player): mort i respawn (DYING + death_spinning + lives counter) --- data/entities/mummy/mummy.yaml | 5 + data/player/morcus.yaml | 31 +++++ data/player/sam.yaml | 31 +++++ source/game/entities/concrete/mummy.cpp | 26 +++- source/game/entities/concrete/mummy.hpp | 7 + .../entities/entity_manager_loadlevel.cpp | 5 +- source/game/entities/player.cpp | 128 +++++++++++++++++- source/game/entities/player.hpp | 74 ++++++++++ source/game/scenes/game_scene.cpp | 21 +++ source/game/scenes/game_scene.hpp | 7 + tests/test_animated_sprite_flip.cpp | 49 +++++++ 11 files changed, 377 insertions(+), 7 deletions(-) diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 9c00d8e..a59e02a 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -42,6 +42,11 @@ ai: stats: speed: 30 damage: 1 + # Quan true, el contacte amb el jugador no aplica `hurt` ni knockback: + # mata d'un sol toc i dispara el cicle DYING → respawn. El path `hurt` + # del jugador segueix funcionant per a futurs hazards no-letals (avui + # no n'hi ha cap que el dispari). + kills_outright: true # Estat HURT: post-fuetada, fugint en línia recta. Si la direcció # preferida (la del whip) està bloquejada, prova perpendiculars; si totes diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 6ae578d..aedb8e1 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -50,6 +50,29 @@ animations: recover_east: { frames: [31] } recover_west: { frames: [31], flip_h: true } + # Animació de mort: 3 voltes horàries començant pel sud (south → + # west → north → east, repetit 3×). Reaprofita els frames stand_*; el + # west és l'east amb `flip_h: true` declarat per-frame (mapping form, + # no hereta del nivell d'anim). `frame_ms` per-frame creix linealment + # per donar la sensació de desaccelerar fins a aturar-se. `loop: false` + # → es queda al darrer frame i marca completed. + death_spinning: + frames: + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] + loop: false + # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). inertia: @@ -93,3 +116,11 @@ whip: cooldown_ms: 200 damage: 1 sound: whip.wav + +# Mort + respawn. `respawn_delay_ms` és la pausa entre la fi de +# `death_spinning` i la reaparició al spawn. `lives` és el comptador +# inicial d'intents (estil Mario): cada respawn el decrementa en 1, pot +# anar a negatius (no hi ha game over). +death: + respawn_delay_ms: 500 + lives: 3 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index f008835..867899f 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -50,6 +50,29 @@ animations: recover_east: { frames: [31] } recover_west: { frames: [31], flip_h: true } + # Animació de mort: 3 voltes horàries començant pel sud (south → + # west → north → east, repetit 3×). Reaprofita els frames stand_*; el + # west és l'east amb `flip_h: true` declarat per-frame (mapping form, + # no hereta del nivell d'anim). `frame_ms` per-frame creix linealment + # per donar la sensació de desaccelerar fins a aturar-se. `loop: false` + # → es queda al darrer frame i marca completed. + death_spinning: + frames: + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] + loop: false + # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # tocs curts (passets d'un tile sense rellisquera). inertia: @@ -93,3 +116,11 @@ whip: cooldown_ms: 200 damage: 1 sound: whip.wav + +# Mort + respawn. `respawn_delay_ms` és la pausa entre la fi de +# `death_spinning` i la reaparició al spawn. `lives` és el comptador +# inicial d'intents (estil Mario): cada respawn el decrementa en 1, pot +# anar a negatius (no hi ha game over). +death: + respawn_delay_ms: 500 + lives: 3 diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 9b1cc0c..b13cd29 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -87,11 +87,15 @@ Mummy::Mummy(const fkyaml::node& yaml) collides_ = true; interactable_ = false; - // Velocitat i dany des del bloc `stats` (si existeix). + // Velocitat i dany des del bloc `stats` (si existeix). El flag + // `kills_outright` també hi viu: per defecte true (la mòmia mata + // d'un sol toc); posat a false al YAML, el contacte només publica + // `player_hit` sense disparar el cicle DYING. if (yaml.contains("stats")) { const auto& s = yaml["stats"]; if (s.contains("speed")) { setSpeed(s["speed"].get_value()); } if (s.contains("damage")) { setDamage(s["damage"].get_value()); } + if (s.contains("kills_outright")) { kills_outright_ = s["kills_outright"].get_value(); } } // Direcció inicial llegida del bloc `ai:`. Per defecte est, per @@ -324,11 +328,25 @@ void Mummy::applyChoice(Direction dir) { } void Mummy::onPlayerContact(Player* player) { - if (bus_ != nullptr) { + if (bus_ == nullptr) { return; } + const int PID = (player != nullptr) ? player->getId() : -1; + bus_->publish(Event{ + .name = "player_hit", + .payload = getDamage(), + .player_id = PID, + }); + // Path letal: si la mòmia està marcada com a `kills_outright` + // (default a Oh Mummy!), notifiquem al GameScene que el jugador + // ha de morir. La transició concreta (`Player::enterDying`) la fa + // el subscriptor — així mummy.cpp no toca cap símbol del Player més + // enllà de la forward-decl, i els tests d'entitats no han d'arrossegar + // l'stack pesat del jugador per linkar-se. El subscriptor (GameScene) + // és qui resol el `player_id` a la instància concreta. + if (kills_outright_) { bus_->publish(Event{ - .name = "player_hit", + .name = "player_killed", .payload = getDamage(), - .player_id = (player != nullptr) ? player->getId() : -1, + .player_id = PID, }); } } diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index c99e0c7..9e78db3 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -69,6 +69,7 @@ class Mummy : public DynamicHazard { [[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto isWhippable() const -> bool { return whippable_; } [[nodiscard]] auto getHurtRemainingS() const -> float { return hurt_remaining_s_; } + [[nodiscard]] auto killsOutright() const -> bool { return kills_outright_; } private: // Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy). @@ -146,4 +147,10 @@ class Mummy : public DynamicHazard { bool whippable_{true}; float hurt_remaining_s_{0.0F}; float hurt_speed_{0.0F}; + + // Si true, el contacte amb el jugador dispara `Player::enterDying` + // (mort d'un sol toc, bypass del flux `hurt`/knockback). Llegit del + // bloc `stats.kills_outright` del YAML; default true per coherència + // amb el comportament d'Oh Mummy! (les mòmies maten). + bool kills_outright_{true}; }; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 290a9b3..99dee18 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -65,7 +65,10 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { - if (p == nullptr || p->isOutOfPlay()) { continue; } + // Filtrem també DYING: el jugador en cicle de mort no rep + // 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; } // 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 01e8861..d92f07a 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -69,6 +69,7 @@ Player::InertiaConfig Player::inertia_config; Player::JumpConfig Player::jump_config; Player::HurtConfig Player::hurt_config; Player::WhipConfig Player::whip_config; +Player::DeathConfig Player::death_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); @@ -120,6 +121,11 @@ void Player::loadFrom(const std::string& yaml_path) { // jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc // d'input es perd). readWhipBlock(yaml); + + // Bloc opcional `death:` — pausa de respawn i comptador inicial de + // vides. Si manca, el respawn és immediat i les vides queden a 3 + // per defecte (no és un valor crític: avui no hi ha game over). + readDeathBlock(yaml); } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { @@ -186,6 +192,14 @@ void Player::readHurtBlock(const fkyaml::node& yaml) { } } +void Player::readDeathBlock(const fkyaml::node& yaml) { + death_config = DeathConfig{}; + if (!yaml.contains("death")) { return; } + const auto& d = yaml["death"]; + if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value(); } + if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value(); } +} + void Player::readWhipBlock(const fkyaml::node& yaml) { whip_config = WhipConfig{}; if (!yaml.contains("whip")) { return; } @@ -343,6 +357,13 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont // prem cap tecla. facing_ = Facing::SOUTH; setCurrentAnimation("stand_south"); + // Comptador d'intents inicialitzat amb el valor del YAML (default 3 + // si el bloc `death:` manca). Fora del cicle DYING no toca a ningú. + lives_remaining_ = death_config.initial_lives; + // Spawn point inicial: la posició del ctor val per defecte; el + // GameScene el reescriu via `setSpawnPoint` quan canvia de sala. + spawn_x_ = x; + spawn_y_ = y; } void Player::update(float delta_time) { @@ -378,9 +399,13 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { const bool MY_AIR = (state_ == State::AIRBORNE); const bool MY_HURT = (state_ == State::HURT); const float MY_Z = jump_z_; - return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) { + const bool MY_DYING = (state_ == State::DYING); + return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_DYING, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } if (p->isOutOfPlay()) { return false; } + // DYING és intangible per a PvP: el jugador mort no actua de + // mur, i un altre jugador en DYING no pot empentar tampoc. + if (MY_DYING || p->isDying()) { return false; } if (MY_HURT || p->isHurt()) { return false; } if (MY_AIR != p->isAirborne()) { return false; } // Aquí els dos estats coincideixen. Si tots dos volen, el filtre @@ -436,6 +461,25 @@ void Player::tick(float delta_time) { // que GameScene el reactiva (respawn / spawnFromAir). if (isOutOfPlay()) { return; } + // DYING: animació `death_spinning` + pausa de respawn. Ignorem tot + // l'input (ni moviment, ni salt, ni atac). El moviment queda + // congelat al lloc de la mort; `applyMovement` avança l'animació i + // detecta la fi per disparar el respawn. Mantenim els flancs de + // botó actualitzats perquè una tecla sostinguda no es comporte com + // a flanc nou en sortir del cicle. + if (state_ == State::DYING) { + (void)consumeJumpEdge(); + (void)consumeAttackEdge(); + 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; + } + // Reset per frame: el resolver del GameScene el tornarà a posar a // true si aquest frame hi ha push actiu. Així quan el push acaba // (un deixa d'empentar, l'altre comença a caminar), l'animació torna @@ -877,7 +921,9 @@ void Player::resolveLandingOverlap() { if (other == nullptr || other == this) { continue; } // Salta jugadors fora del joc: un caigut sobre un slot DISABLED // (o sobre un EXITED encara al vector) no els ha de projectar. - if (other->isOutOfPlay()) { continue; } + // També DYING — un jugador en cicle de mort no rep cap més + // efecte fins que respawneja. + if (other->isOutOfPlay() || other->isDying()) { continue; } // Test amb la hit-box (8×8 de contacte precís), no la base-box. // Coherent amb la resta de col·lisions actor-actor del joc: // momies, fuetades futures, resolver PvP… tots usen hit-box. Un @@ -925,6 +971,11 @@ void Player::applyMovement(float delta_time) { resolveLandingOverlap(); } + if (state_ == State::DYING) { + applyDyingMovement(delta_time); + return; + } + if (state_ == State::HURT) { applyHurtMovement(delta_time); return; @@ -996,6 +1047,67 @@ void Player::applyMovement(float delta_time) { if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } } +void Player::enterDying() { + // 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 + // porta, DISABLED durant les transicions de sala). + if (state_ == State::DYING || isOutOfPlay()) { return; } + + state_ = State::DYING; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + input_vx_ = 0.0F; + input_vy_ = 0.0F; + motion_time_ms_ = 0.0F; + being_pushed_ = false; + jump_z_ = 0.0F; + jump_vz_ = 0.0F; + landing_stun_remaining_ms_ = 0.0F; + attacking_remaining_ms_ = 0.0F; + turning_remaining_ms_ = 0.0F; + pending_cardinal_ = Cardinal::NONE; + intermediate_cardinal_ = Cardinal::NONE; + last_input_cardinal_ = Cardinal::NONE; + setVelX(0.0F); + setVelY(0.0F); + + death_animation_complete_ = false; + respawn_remaining_ms_ = 0.0F; + setCurrentAnimation("death_spinning"); +} + +void Player::applyDyingMovement(float delta_time) { + // Avancem l'animació; el sprite no es mou (la posició queda + // congelada al punt de la mort). `animationIsCompleted()` flanqueja + // un cop la roda arriba al darrer frame (loop:false). Llavors + // descomptem `respawn_delay_ms` mantenint el sprite al darrer frame + // i, en arribar a 0, disparem el respawn. + animate(delta_time); + if (!death_animation_complete_ && animationIsCompleted()) { + death_animation_complete_ = true; + respawn_remaining_ms_ = static_cast(death_config.respawn_delay_ms); + } + if (death_animation_complete_) { + respawn_remaining_ms_ -= delta_time * 1000.0F; + if (respawn_remaining_ms_ <= 0.0F) { + respawnFromDying(); + } + } +} + +void Player::respawnFromDying() { + // Vides: decreix sempre. Pot anar a negatius — no hi ha game over. + --lives_remaining_; + death_animation_complete_ = false; + respawn_remaining_ms_ = 0.0F; + // `respawn` reseteja l'estat (GROUNDED, facing SOUTH, animació + // `stand_south`) i mou el sprite a les coords del spawn que el + // GameScene ha assignat. No fem `spawnFromAir` per disseny: el + // respawn post-mort és sec, sense cinemàtica de caiguda. + respawn(spawn_x_, spawn_y_); +} + void Player::applyHurtMovement(float delta_time) { // En HURT el jugador es desplaça segons `intended_v*` (escrit a // `enterHurt` i decremental a `tick`). Si `tryMove` no avança @@ -1133,6 +1245,12 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons return "hurt"; case State::ATTACKING: return in_attack_swing ? "attack" : "recover"; + case State::DYING: + // L'animació concreta (`death_spinning`) la fixa `enterDying`; + // `applyDyingMovement` no torna a cridar `setCurrentAnimation`, + // així que aquest valor no s'usa mai. El retornem només per + // exhaustivitat del switch. + return "death"; case State::GROUNDED: case State::EXITED: case State::DISABLED: @@ -1199,10 +1317,16 @@ void Player::respawn(float x, float y) { jump_z_ = 0.0F; jump_vz_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; + attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; + // Flags del cicle DYING (que `respawnFromDying` també neteja, però + // si algú crida `respawn` directament des d'un altre estat, han de + // quedar coherents per al pròxim cicle de mort). + death_animation_complete_ = false; + respawn_remaining_ms_ = 0.0F; // Reseteig del facing al d'spawn original (mirant cap a la càmera): // sense això, un EXITED que entrava per la porta amb facing NORTH // re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor. diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 3d19d7d..64b328d 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -68,6 +68,13 @@ class Player : public AnimatedSprite { // tots els slots durant les transicions de sala abans del spawn // aeri, i (c) en el futur, l'estat post-mort abans del respawn. DISABLED, + // Mort en curs: animació `death_spinning` (3 voltes horàries) + + // pausa `respawn_delay_ms` abans del respawn. Bloqueja input i + // moviment; no rep contacte amb hazards (cf. `isDying`). Quan el + // timer s'esgota, `respawn(spawn_x_, spawn_y_)` torna a GROUNDED + // i decrementa el comptador de vides (pot anar a negatius — no + // hi ha game over). + DYING, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -203,6 +210,7 @@ class Player : public AnimatedSprite { [[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; } [[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; } [[nodiscard]] auto isDisabled() const -> bool { return state_ == State::DISABLED; } + [[nodiscard]] auto isDying() const -> bool { return state_ == State::DYING; } // Resum dels estats "fora del món": EXITED i DISABLED comparteixen el // mateix perfil físic (no es pinten, no llegeixen input, no // col·lisionen, no participen al resolver PvP). Tots dos gates de @@ -240,6 +248,28 @@ class Player : public AnimatedSprite { being_pushed_ = false; } + // Mata el jugador d'un sol toc: transició a DYING, animació + // `death_spinning`, posició congelada, input ignorat. Idempotent — + // si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de + // 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(); + + // Coordenades del spawn de respawn (cantó superior-esquerra del + // sprite, mateixa convenció que `respawn(x, y)`). El GameScene les + // assigna després de `computeSpawn` al ctor i a cada `loadRoom`. Sense + // això, el respawn enviaria el jugador a (0, 0). + void setSpawnPoint(float x, float y) { + spawn_x_ = x; + spawn_y_ = y; + } + + // Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn; + // no es clampa a 0 — permet anar a negatius perquè avui no hi ha + // game over. Públic per a HUD i tests. + [[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; } + // Reset complet a l'estat inicial: posició a (x, y), velocitats a 0, // estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat // per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent @@ -554,6 +584,21 @@ class Player : public AnimatedSprite { // d'input es perd silenciosament). static void readWhipBlock(const fkyaml::node& yaml); + // Configuració de mort/respawn llegida del bloc `death:` del YAML. + // `respawn_delay_ms` és la pausa entre la fi de `death_spinning` i la + // reaparició al spawn; `initial_lives` és el comptador d'intents amb + // què naix cada Player. Si el bloc manca, defaults segurs (0/3). + struct DeathConfig { + int respawn_delay_ms{0}; + int initial_lives{3}; + }; + static DeathConfig death_config; + + // Llig el bloc `death:` del YAML i n'omple `death_config`. Si manca, + // `respawn_delay_ms` queda a 0 (el respawn és immediat al final de + // l'animació) i `initial_lives` es manté al default (3). + static void readDeathBlock(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 @@ -624,6 +669,35 @@ class Player : public AnimatedSprite { // bloquejat i no rep input direccional ni de salt ni d'atac. float attacking_remaining_ms_{0.0F}; + // Estat de DYING. `death_animation_complete_` es marca quan + // `animationIsCompleted()` retorna true per primera vegada al cicle + // actual; només llavors comença a descomptar `respawn_remaining_ms_`. + // Quan el delay arriba a 0, `respawnFromDying()` aplica `respawn(...)` + // i decreix el comptador de vides. + bool death_animation_complete_{false}; + float respawn_remaining_ms_{0.0F}; + + // Comptador d'intents (estil Mario). Inicialitzat a `death_config. + // initial_lives` al ctor; decreix una vegada per cada respawn + // post-mort (no per respawns de canvi de sala). Pot anar a negatius: + // avui no hi ha game over. + int lives_remaining_{0}; + + // Coordenades del spawn de respawn (cantó superior-esquerra del + // sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que + // el GameScene n'invoque el setter al ctor / `loadRoom`. + float spawn_x_{0.0F}; + float spawn_y_{0.0F}; + + // Aplica el respawn al final del cicle DYING: decreix vides (sense + // clamp), neteja els flags interns del cicle i delega a `respawn` + // amb les coordenades del spawn. + void respawnFromDying(); + + // Branca de `applyMovement` per a DYING. Avança l'animació, detecta + // la fi i descompta el delay; al final, dispara el respawn. + void applyDyingMovement(float delta_time); + // 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; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 5a790f0..167d3c3 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -298,6 +298,14 @@ GameScene::GameScene(Config config) std::cout << "[PLAYER HIT] player_id=" << ev.player_id << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); + // `player_killed` el publica un hazard letal (mòmia amb + // `kills_outright`) després del `player_hit`. Aquí resolem el + // player_id a la instància concreta i disparem el cicle DYING. El + // hazard no toca cap símbol de Player directament, així els binaris + // de tests d'entitats no han d'arrossegar tot l'stack del jugador. + entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) { + dispatchPlayerKilled(ev.player_id); + }); // Polvet: precarreguem el surface del Dust una sola vegada perquè // els spawns runtime de l'entitat no facin disc al primer salt. @@ -602,6 +610,11 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) { float x = SINGLE_X; if (BOTH_ACTIVE) { x = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; } + // Memoritzem aquestes coordenades com a punt de respawn post-mort. + // El Player les usa al final del cicle DYING per teleportar-se. Les + // refresquem cada vegada que disparem un spawn aeri perquè la sala + // pot canviar de geometria de porta entre cicles. + players_[slot_index]->setSpawnPoint(x, SPAWN.y); players_[slot_index]->spawnFromAir(x, SPAWN.y); // Arma el reverb del primer LAND com a part de la cinemàtica // d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort @@ -612,6 +625,14 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) { std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n"; } +void GameScene::dispatchPlayerKilled(int player_id) { + std::cout << "[PLAYER KILLED] player_id=" << player_id << '\n'; + const auto IT = std::ranges::find_if(players_, [player_id](const auto& p) { + return p && p->getId() == player_id; + }); + if (IT != players_.end()) { (*IT)->enterDying(); } +} + void GameScene::completeIntro() { if (intro_completed_) { return; } intro_completed_ = true; diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index f3d7efe..7619223 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -120,6 +120,13 @@ class GameScene : public Scene { // commuta a PLAYING — la música del joc arrenca amb el primer slot. void completeIntro(); + // Dispatch del cicle DYING al jugador identificat per `player_id`. + // Crida `Player::enterDying()` (idempotent — un segon impacte mentre + // ja és DYING és no-op). Subscriptor de l'event `player_killed` + // publicat pels hazards letals (mòmia amb `kills_outright`). + // Extret a un mètode per acotar la complexitat cognitiva del ctor. + void dispatchPlayerKilled(int player_id); + // Itera els slots actius i, si el delay configurat ha expirat des // del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`. // Cridada cada frame durant la fase d'spawn escalonat (entre el diff --git a/tests/test_animated_sprite_flip.cpp b/tests/test_animated_sprite_flip.cpp index 1ec9e1c..7864b21 100644 --- a/tests/test_animated_sprite_flip.cpp +++ b/tests/test_animated_sprite_flip.cpp @@ -167,6 +167,54 @@ animations: } } + void testDeathSpinning() { + std::cout << "[T6] death_spinning: 12 frames + frame_ms list + loop:false\n"; + // Mateix bloc que sam.yaml/morcus.yaml. 3 voltes horàries: per + // cada volta, sud (idx 0) → oest (idx 8 amb flip_h) → nord (idx 4) + // → est (idx 8). 12 frames totals; rampa lineal de durades; sense + // loop (es queda al darrer frame). + const std::string YAML = R"( +animations: + death_spinning: + frames: + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + - 0 + - { index: 8, flip_h: true } + - 4 + - 8 + frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] + loop: false +)"; + const auto ANIMS = parse(YAML); + const auto IT = ANIMS.find("death_spinning"); + REQUIRE(IT != ANIMS.end()); + if (IT == ANIMS.end()) { return; } + const auto& A = IT->second; + + REQUIRE(A.frames.size() == 12); + REQUIRE(A.speeds.size() == 12); + REQUIRE(A.loop_from == -1); // loop:false → -1 + + // Patró cíclic: posicions 1,5,9 (oest) tenen flip_h=true; la resta no. + for (size_t i = 0; i < A.frames.size(); ++i) { + const bool IS_WEST = (i % 4 == 1); + REQUIRE(A.frames[i].flip_h == IS_WEST); + REQUIRE(!A.frames[i].flip_v); + } + + // Rampa de durades creixent (acceleració negativa visual). + for (size_t i = 1; i < A.speeds.size(); ++i) { + REQUIRE(A.speeds[i] > A.speeds[i - 1]); + } + } + void testCombinedVerticalAndHorizontal() { std::cout << "[T5] flip combinat V+H per-frame\n"; const std::string YAML = R"( @@ -197,6 +245,7 @@ auto main() -> int { testOverridePerFrame(); testMappingDoesNotInherit(); testCombinedVerticalAndHorizontal(); + testDeathSpinning(); if (g_failures == 0) { std::cout << "ALL OK\n";