From f5d251c33bd9886537ec2d2bc4ab726b53f4a744 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 19:51:42 +0200 Subject: [PATCH] feat(stats): Player com a font de veritat de vides/score/tresors i sarcs amb recompensa --- data/entities/sarcophagus/sarcophagus.yaml | 6 +++ source/game/entities/concrete/sarcophagus.cpp | 27 +++++++++++++- source/game/entities/concrete/sarcophagus.hpp | 24 +++++++++++- source/game/entities/entity.cpp | 2 +- source/game/entities/entity.hpp | 8 +++- source/game/entities/entity_manager.cpp | 13 ++++--- source/game/entities/entity_manager.hpp | 9 +++-- source/game/entities/event_bus.hpp | 29 +++++++++++++-- source/game/entities/player.cpp | 37 +++++++++++++++++++ source/game/entities/player.hpp | 26 +++++++++++++ source/game/scenes/game_scene.cpp | 22 +++++++++++ source/game/scenes/game_scene.hpp | 8 ++++ source/game/ui/hud.cpp | 37 ++++++++++++++----- tests/test_entities_smoke.cpp | 12 +++--- 14 files changed, 226 insertions(+), 34 deletions(-) diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index 108f818..e6466b3 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -40,3 +40,9 @@ behaviour: sound: opening: open_sarc.wav opening_pitch_spread: 0.5 +# Recompensa que rep el jugador que pinta el tile que tanca el perímetre +# (el GameScene la reparteix consumint `sarcophagus_opened`). Si es lleva +# aquest bloc o un dels camps, el camp absent val 0 i no s'aplica. +reward: + points: 100 + treasures: 1 diff --git a/source/game/entities/concrete/sarcophagus.cpp b/source/game/entities/concrete/sarcophagus.cpp index 8914099..2bea0dd 100644 --- a/source/game/entities/concrete/sarcophagus.cpp +++ b/source/game/entities/concrete/sarcophagus.cpp @@ -83,6 +83,15 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml) opening_pitch_spread_ = s["opening_pitch_spread"].get_value(); } } + + // Bloc `reward:` opcional — la recompensa que rebrà el jugador que + // tanqui el sarcòfag. Si no hi és, els camps queden a 0 i el GameScene + // simplement no aplica res al consumir `sarcophagus_opened`. + if (yaml.contains("reward")) { + const auto& r = yaml["reward"]; + if (r.contains("points")) { points_reward_ = r["points"].get_value(); } + if (r.contains("treasures")) { treasures_reward_ = r["treasures"].get_value(); } + } } void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) { @@ -135,18 +144,32 @@ void Sarcophagus::update(float delta_time) { anim_ = anim_open_; if (anim_.has_value()) { anim_->reset(); } if (!opened_event_emitted_ && bus_ != nullptr) { - bus_->publish("sarcophagus_opened", static_cast(this)); + // Publica amb `player_id = last_closer_player_id_` perquè el + // GameScene pugui atribuir la recompensa al jugador correcte. + // El payload manté el punter al sarc perquè el consumidor + // pugui llegir `getPointsReward()` i `getTreasuresReward()`. + bus_->publish(Event{ + .name = "sarcophagus_opened", + .payload = static_cast(this), + .player_id = last_closer_player_id_, + }); opened_event_emitted_ = true; } } } -void Sarcophagus::notifyTilePainted(int col, int row) { +void Sarcophagus::notifyTilePainted(int col, int row, int player_id) { if (state_ != State::CLOSED) { return; } const TileKey KEY{.col = col, .row = row}; if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; } visited_.insert(KEY); if (visited_.size() >= perimeter_tiles_.size()) { + // Atribució: el jugador que pinta el tile que tanca el cercle es + // queda amb la recompensa. Si el broadcast inicial de càrrega + // arribara a tancar un sarc (no hauria de passar perquè els tiles + // d'aquest broadcast són decals/murs, no de corredor), `player_id` + // seria -1 i el GameScene saltarà el repartiment. + last_closer_player_id_ = player_id; state_ = State::OPENING; anim_ = anim_opening_; if (anim_.has_value()) { anim_->reset(); } diff --git a/source/game/entities/concrete/sarcophagus.hpp b/source/game/entities/concrete/sarcophagus.hpp index d28bc86..20f83fc 100644 --- a/source/game/entities/concrete/sarcophagus.hpp +++ b/source/game/entities/concrete/sarcophagus.hpp @@ -46,7 +46,7 @@ class Sarcophagus : public Interactable { void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked); void update(float delta_time) override; - void notifyTilePainted(int col, int row) override; + void notifyTilePainted(int col, int row, int player_id) override; void onRemoved() override; [[nodiscard]] auto getState() const -> State { return state_; } @@ -58,6 +58,17 @@ class Sarcophagus : public Interactable { [[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; } [[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; } + // Recompensa que es donarà al jugador que pinta el tile que tanca el + // perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc; + // 0/0 si el bloc `reward:` no existeix. El GameScene escolta + // `sarcophagus_opened` i aplica aquests valors al Player corresponent. + [[nodiscard]] auto getPointsReward() const -> int { return points_reward_; } + [[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; } + // Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1 + // si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el + // broadcast inicial de càrrega) — cas que el GameScene ignora. + [[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; } + private: struct TileKey { int col; @@ -94,5 +105,16 @@ class Sarcophagus : public Interactable { // obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0. float opening_pitch_spread_{0.0F}; + // Recompenses llegides del YAML (bloc `reward:`). 0/0 si el bloc no + // hi és — el GameScene salta el pas de repartiment quan són 0. + int points_reward_{0}; + int treasures_reward_{0}; + + // Jugador que ha pintat el tile que tanca el perímetre. Es fixa una + // sola vegada, en la transició CLOSED→OPENING dins de notifyTilePainted. + // -1 mentre està a CLOSED, o si la pintada que el tanca no porta + // atribució (player_id == -1 al broadcast inicial). + int last_closer_player_id_{-1}; + bool opened_event_emitted_{false}; }; diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index d8586ca..f235f5a 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -24,7 +24,7 @@ void Entity::onPlayerContact(Player* /*player*/) { // No-op per defecte. } -void Entity::notifyTilePainted(int /*col*/, int /*row*/) { +void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) { // No-op per defecte; només `Sarcophagus` l'usa. } diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 068669a..05c655c 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -84,8 +84,12 @@ class Entity { // Hook que el `EntityManager` invoca per a cada entitat cada vegada // que un tile es pinta (via PaintLayer, com a conseqüència d'un // jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix - // (per marcar perímetre); per defecte no-op. - virtual void notifyTilePainted(int col, int row); + // (per marcar perímetre); per defecte no-op. `player_id` és l'id del + // jugador que ha pintat el tile, o -1 si la pintada ve del broadcast + // inicial de càrrega de nivell (decals + murs, sense atribució). Els + // consumidors han de filtrar per `player_id >= 0` si necessiten + // atribució. + virtual void notifyTilePainted(int col, int row, int player_id); // L'entitat ha rebut un fueteig amb solapament. Es passa el punter al // `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index d995d32..403bdab 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -31,7 +31,7 @@ EntityManager::EntityManager() { // els sarcòfags puguin actualitzar el seu `visited_`. tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) { const auto* tile = std::get_if(&ev.payload); - if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); } + if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); } }); // Subscripció a `player_exited` (publicat per Door en travessar la @@ -141,12 +141,13 @@ void EntityManager::update(float delta_time) { std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); } -void EntityManager::notifyPlayerTilePainted(int col, int row) { +void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) { // Fan-out polimòrfic a totes les entitats: cada una decideix què fer - // (`Sarcophagus` actualitza el seu perímetre, la resta queden en - // no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs; - // qui paga el cost guanya simplicitat al callsite. - for (auto& e : entities_) { e->notifyTilePainted(col, row); } + // (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar + // el cercle, la resta queden en no-op). `player_id` ve del bus + // (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de + // nivell, els consumidors filtren si necessiten atribució. + for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); } } auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 101ccd8..7641b7b 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -21,9 +21,10 @@ class DynamicHazard; // 2. `update(dt)` propaga el tick a totes. // 3. Comptador de sarcòfags oberts — quan iguala el total registrat, // crida `door_->open()` automàticament. -// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots -// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via -// `notifyTilePainted`). +// 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook +// per a tots els sarcòfags (i qualsevol entitat futura que s'hi +// subscrigui via `notifyTilePainted`). El `player_id` permet +// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc). // // NO és un singleton: es crea una instància per nivell. De moment no // s'usa encara al GameScene (andamiatge pur). @@ -103,7 +104,7 @@ class EntityManager { // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // (veure Entity::notifyTilePainted). Invocat com a reacció a // `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat). - void notifyPlayerTilePainted(int col, int row); + void notifyPlayerTilePainted(int col, int row, int player_id); // Carrega els spawns d'un YAML de nivell i els afegeix al manager: // 1. Llegeix el bloc `entities:` via EntityLoader. diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index 5d414db..5fef1be 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -19,12 +19,21 @@ 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 // via `Entity::setEventBus`. Aquest sistema cobreix les necessitats -// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered) -// sense portar-hi senyalització més pesada. +// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered, +// player_stats_changed) sense portar-hi senyalització més pesada. // // Decisió: `std::variant` per al payload — evita any/void* i facilita // consumir events tipats amb `std::get_if`. Si apareixen més tipus de // payload, s'amplia aquí sense tocar cridants existents. +// +// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player` +// és la font de veritat de vides/score/tresors i el publica cada vegada +// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot +// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així +// qualsevol via que modifique stats al Player (mort per forat, recompensa +// de sarc, etc.) queda automàticament reflectida sense que cada caller +// haja de recordar publicar res addicional. `player_hit` queda només per +// al feedback visual (flash/shake), separat del comptador. // Tile (col, row) — payload de `tile_painted` (i altres events de tile sense // metadada extra). No és un `pair` per evitar alias ambigus al @@ -74,9 +83,20 @@ struct WhipSpawnPayload { float range_px{0.0F}; }; +// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`. +// El Player publica un d'aquests cada vegada que muta vides/score/tresors. +// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua +// caché amb aquests valors. El `player_id` de l'`Event` indica a quin +// bloc del HUD va dirigit. +struct PlayerStatsPayload { + int lives{0}; + int score{0}; + int treasures{0}; +}; + 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 @@ -129,6 +149,9 @@ class EventBus { void publish(const std::string& name, const WhipSpawnPayload& whip) { publish(Event{.name = name, .payload = whip}); } + void publish(const std::string& name, const PlayerStatsPayload& stats) { + publish(Event{.name = name, .payload = stats}); + } private: struct Subscription { diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 6924bc2..78f093b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -1272,6 +1272,37 @@ void Player::applyDyingMovement(float delta_time) { void Player::resetForJoin() { lives_remaining_ = death_config.initial_lives; + score_ = 0; + treasures_ = 0; + // Publica l'estat inicial perquè el HUD reompla el bloc en el moment + // de fer join (no només quan el comptador canvia després). Sense açò + // el HUD es quedaria amb la caché prèvia fins al primer event. + publishStatsChanged(); +} + +void Player::publishStatsChanged() { + if (bus_ == nullptr) { return; } + bus_->publish(Event{ + .name = "player_stats_changed", + .payload = PlayerStatsPayload{ + .lives = lives_remaining_, + .score = score_, + .treasures = treasures_, + }, + .player_id = id_, + }); +} + +void Player::addScore(int delta) { + if (delta == 0) { return; } + score_ += delta; + publishStatsChanged(); +} + +void Player::addTreasures(int delta) { + if (delta == 0) { return; } + treasures_ += delta; + publishStatsChanged(); } void Player::respawnFromDying() { @@ -1283,6 +1314,12 @@ void Player::respawnFromDying() { --lives_remaining_; death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; + // Notifica al HUD del nou comptador de vides abans de qualsevol + // transició d'estat. Aquesta crida tanca el bucle de qualsevol via + // de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i + // ningú més ha de recordar publicar res. Era exactament el punt que + // s'oblidava al camí del forat. + publishStatsChanged(); if (lives_remaining_ <= 0) { disable(); if (bus_ != nullptr) { diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 33a3ce2..f85d4d3 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -158,6 +158,21 @@ class Player : public AnimatedSprite { // xarxa — és únic per dins del procés. [[nodiscard]] auto getId() const -> int { return id_; } + // Stats que pinta el HUD. El Player n'és la font de veritat; cada + // mutació es publica via `player_stats_changed` al bus (vegeu + // `publishStatsChanged`). Els getters són per a tests i diagnòstic; + // el HUD no els consulta — escolta el bus. + [[nodiscard]] auto getLives() const -> int { return lives_remaining_; } + [[nodiscard]] auto getScore() const -> int { return score_; } + [[nodiscard]] auto getTreasures() const -> int { return treasures_; } + + // Acumuladors. Deltas positius i negatius (sense clamp). Publiquen + // `player_stats_changed` només si el delta no és 0, per estalviar + // soroll al bus. Els consumidors típics: GameScene reparteix puntuació + // de sarcs i pickups (futur) cridant aquests mètodes. + void addScore(int delta); + void addTreasures(int delta); + // Accés i mutació de la velocitat intencionada d'aquest frame. El // GameScene els toca entre `tick` i `applyMovement` per implementar // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en @@ -852,6 +867,17 @@ class Player : public AnimatedSprite { // inicial quan el jugador re-entra mid-partida. int lives_remaining_{0}; + // Puntuació acumulada i nombre de tresors recollits. Es modifiquen + // via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD + // els sincronitza via `player_stats_changed`. + int score_{0}; + int treasures_{0}; + + // Publica un snapshot complet (vides/score/tresors) al bus si està + // injectat. Centralitza la generació de l'event perquè cap mutador + // se l'oblide. + void publishStatsChanged(); + // 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`. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index d63ea26..22deca0 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -26,6 +26,7 @@ #include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/concrete/dust.hpp" +#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/whip_attack.hpp" #include "game/entities/entity.hpp" #include "game/entities/entity_factory.hpp" @@ -321,6 +322,12 @@ GameScene::GameScene(Config config) entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) { onPlayerDisabled(ev.player_id); }); + // `sarcophagus_opened`: extret a `onSarcophagusOpened` per acotar + // la complexitat cognitiva del ctor (mateix patró que `player_killed` + // i `player_disabled`). + entity_manager_.getBus().subscribe("sarcophagus_opened", [this](const Event& ev) { + onSarcophagusOpened(ev); + }); // Polvet: precarreguem el surface del Dust una sola vegada perquè // els spawns runtime de l'entitat no facin disc al primer salt. @@ -732,6 +739,21 @@ void GameScene::onPlayerDisabled(int player_id) { players_active_[player_id] = false; } +void GameScene::onSarcophagusOpened(const Event& ev) { + // `player_id < 0`: pintada sense atribució (broadcast inicial). No + // hauria de tancar mai un sarc, però per defensa ignorem. + if (ev.player_id < 0) { return; } + const auto IDX = static_cast(ev.player_id); + if (IDX >= players_.size()) { return; } + const auto* ent = std::get_if(&ev.payload); + if (ent == nullptr || *ent == nullptr) { return; } + auto* sarc = dynamic_cast(*ent); + if (sarc == nullptr) { return; } + auto& p = *players_[IDX]; + p.addScore(sarc->getPointsReward()); + p.addTreasures(sarc->getTreasuresReward()); +} + void GameScene::preloadGameOverAssets() const { // Pre-load del so del countdown: el dispara `updateGameOverFlow` // cada vegada que canvia el número visible de CONTINUE? (incloent diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 9c1f333..c137899 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -127,6 +127,14 @@ class GameScene : public Scene { // mètode per acotar la complexitat cognitiva del subscriber/ctor. void onPlayerDisabled(int player_id); + // Handler de l'event `sarcophagus_opened` (un jugador acaba de + // tancar el perímetre). Reparteix la recompensa (`points`, + // `treasures` del YAML del sarc) al Player corresponent, que al + // mutar score/treasures publicarà `player_stats_changed` i el HUD + // s'actualitzarà sol. Extret a mètode per acotar la complexitat + // cognitiva del ctor. + void onSarcophagusOpened(const Event& ev); + // Pre-load dels recursos d'àudio que s'usen mid-partida i no els // toca el Director (so del countdown, etc.). Si la clau és buida // o ja està al cache, és no-op. Extret a mètode per acotar la diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp index 7916461..c6a9d72 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -135,10 +135,14 @@ Hud::Hud(EventBus& bus, std::shared_ptr font, int initial_lives) players_[1].active = false; players_[1].lives = initial_lives_; - // Subscripcions al bus de l'EntityManager. De moment només `player_hit` - // afecta el HUD visualment: decrementa vides, dispara flash i shake al - // bloc del jugador. Quan els events evolucionin per portar `player_id` - // i puntuacions, ampliarem aquí sense tocar callers. + // Subscripcions al bus de l'EntityManager. + // + // `player_hit` només fa feedback visual (flash + shake) al bloc del + // jugador colpejat. **No** toca el comptador de vides — la veritat + // viu al Player i arriba per `player_stats_changed`. Així si un hit + // no és mortal (HURT recoverable) el flash es manté coherent sense + // mentir el comptador, i si és mortal el comptador baixa pel canal + // numèric quan el cicle DYING acaba a `respawnFromDying`. subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 @@ -147,14 +151,28 @@ Hud::Hud(EventBus& bus, std::shared_ptr font, int initial_lives) // publishers de `player_hit` actuals sí que porten id. const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; if (!players_[IDX].active) { return; } - if (players_[IDX].lives > 0) { --players_[IDX].lives; } const std::size_t BLK_IDX = (IDX == 0) ? static_cast(BlockId::LEFT) : static_cast(BlockId::RIGHT); auto& blk = blocks_[BLK_IDX]; blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_intensity = 1.5F; - Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives); + Log::info("HUD", "player_hit p{} dmg={}", IDX + 1, dmg != nullptr ? *dmg : 0); + })); + + // `player_stats_changed`: snapshot total (vides/score/tresors) emès + // pel Player cada vegada que muta qualsevol dels tres. El HUD el + // consumeix com a estat absolut: sobreescriu la caché local sense + // raonament addicional. Això unifica totes les fonts de canvi (mort + // per qualsevol causa, recompensa de sarc, futurs pickups…) en un + // únic canal. + subs_.push_back(bus_.subscribe("player_stats_changed", [this](const Event& ev) { + const auto* st = std::get_if(&ev.payload); + if (st == nullptr) { return; } + const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; + players_[IDX].lives = st->lives; + players_[IDX].score = st->score; + players_[IDX].treasures = st->treasures; })); subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) { @@ -207,10 +225,11 @@ void Hud::activatePlayer(int player_one_based) { if (player_one_based < 1 || player_one_based > 2) { return; } auto& p = players_[static_cast(player_one_based - 1)]; p.active = true; - p.lives = initial_lives_; - p.score = 0; - p.treasures = 0; p.join_prompt = JoinPromptMode::NONE; + // Els camps numèrics (lives/score/treasures) ja els ha sobreescrit el + // `player_stats_changed` que el Player publica dins de `resetForJoin`, + // així que aquí no els toquem — evitem trepitjar la veritat del bus + // amb defaults locals. } void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) { diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 0bf7ab7..7b6be0f 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -157,7 +157,7 @@ namespace { REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED); // Visitem un tile que NO és al perímetre: res canvia. - sarc->notifyTilePainted(5, 5); + sarc->notifyTilePainted(5, 5, /*player_id=*/0); REQUIRE(sarc->getVisitedCount() == 0); // Visitem tots els del perímetre. @@ -166,7 +166,7 @@ namespace { {0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2}, {0, 1}, {4, 1}, }; - for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); } + for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r, /*player_id=*/0); } REQUIRE(sarc->getVisitedCount() == 12); REQUIRE(sarc->getState() == Sarcophagus::State::OPENING); @@ -204,12 +204,12 @@ namespace { const int W = s->getTileWidth(); const int H = s->getTileHeight(); for (int dc = -1; dc <= W; ++dc) { - s->notifyTilePainted(COL0 + dc, ROW0 - 1); - s->notifyTilePainted(COL0 + dc, ROW0 + H); + s->notifyTilePainted(COL0 + dc, ROW0 - 1, /*player_id=*/0); + s->notifyTilePainted(COL0 + dc, ROW0 + H, /*player_id=*/0); } for (int dr = 0; dr < H; ++dr) { - s->notifyTilePainted(COL0 - 1, ROW0 + dr); - s->notifyTilePainted(COL0 + W, ROW0 + dr); + s->notifyTilePainted(COL0 - 1, ROW0 + dr, /*player_id=*/0); + s->notifyTilePainted(COL0 + W, ROW0 + dr, /*player_id=*/0); } }; forceOpen(s1);