diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp index c2de2a4..d1f9c43 100644 --- a/source/game/entities/concrete/door.cpp +++ b/source/game/entities/concrete/door.cpp @@ -66,15 +66,22 @@ void Door::update(float delta_time) { } void Door::onPlayerContact(Player* player) { - if (state_ != State::OPEN || level_complete_emitted_) { return; } + // Només la porta OPEN despawna jugadors. CLOSED/OPENING ignoren el + // contacte (i a més, en CLOSED, el `Map::collides` ja bloqueja el + // moviment, així que el contacte no es dóna en condicions normals). + if (state_ != State::OPEN || player == nullptr || player->isExited()) { return; } + // Marquem el jugador com a EXITED i publiquem l'event d'agregació. + // El check `isExited` de la guarda anterior garanteix que només + // emetem l'event en la primera transició — `EntityManager` compta + // emissions per decidir quan publicar `level_complete`. + player->exit(); if (bus_ != nullptr) { bus_->publish(Event{ - .name = "level_complete", + .name = "player_exited", .payload = std::monostate{}, - .player_id = (player != nullptr) ? player->getId() : -1, + .player_id = player->getId(), }); } - level_complete_emitted_ = true; } void Door::open() { diff --git a/source/game/entities/concrete/door.hpp b/source/game/entities/concrete/door.hpp index f3fa9f2..99cfd1d 100644 --- a/source/game/entities/concrete/door.hpp +++ b/source/game/entities/concrete/door.hpp @@ -7,11 +7,14 @@ #include "game/entities/interactable.hpp" // Porta de sortida del nivell. Tres estats: -// CLOSED → inicial; bloqueja el pas. +// CLOSED → inicial; el passadís de Map bloqueja el pas via `Map::collides`. // OPENING → animació d'obertura en curs. Quan el component acaba → -// transita a OPEN. -// OPEN → accessible. Al contacte amb el jugador publica -// `level_complete` al bus (si n'hi ha). +// transita a OPEN. El passadís ja és transitable (s'allibera +// a `open()` amb `Map::door_open = true`). +// OPEN → accessible. Al contacte amb un jugador, crida `Player::exit()` +// — el jugador queda despawnat i emet `player_exited` al bus. +// L'agregació "tots els jugadors han eixit → `level_complete`" +// viu a EntityManager. // // El que decideix quan s'obri és extern (`EntityManager` comptant sarcs // oberts) via `open()`. L'animació `opening` es fa servir com a @@ -38,7 +41,6 @@ class Door : public Interactable { void setAnimation(const AnimationComponent& anim); State state_{State::CLOSED}; - bool level_complete_emitted_{false}; // Les 3 animacions precalculades al ctor des del YAML. L'activa // s'assigna a `anim_` (heretat d'Entity) segons l'estat. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 7577fb6..06d891b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -328,6 +328,10 @@ void Player::update(float delta_time) { } auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { + // EXITED: jugador despawnat. No xoca amb res ni res xoca amb ell. + // També salta jugadors EXITED del check actor↔actor més avall via + // el filtre de `isExited` (vegeu el predicat any_of). + if (state_ == State::EXITED) { return false; } if (map_.collides(box)) { return true; } if (all_players_ == nullptr) { return false; } // Per al contacte actor-actor usem la hit-box petita a banda i banda: @@ -353,6 +357,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { 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) { if (p == nullptr || p == this) { return false; } + if (p->isExited()) { 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 @@ -398,7 +403,15 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot } } +// Pipeline d'estats i input separats; extracció a helpers empitjoraria la +// llegibilitat. La complexitat puja 1 per la guarda EXITED. +// NOLINTNEXTLINE(readability-function-cognitive-complexity) void Player::tick(float delta_time) { + // EXITED: jugador despawnat, no fa res. Cap input, cap física, cap + // bus event. Continua sent al vector de jugadors per simplicitat + // (no s'esborra) però queda completament inert fins al respawn. + if (state_ == State::EXITED) { 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 @@ -740,6 +753,9 @@ void Player::resolveLandingOverlap() { } void Player::applyMovement(float delta_time) { + // EXITED: cap moviment ni col·lisió. Idèntic al gating de `tick`. + if (state_ == State::EXITED) { return; } + // Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_. // El facing i intended_v* es mantenen del moment del despegue (no es // recalculen). Si toquem el sòl aquest frame, transicionem a @@ -859,6 +875,10 @@ void Player::publishTileEnteredIfChanged() { } void Player::render() { + // EXITED: invisible. La crida al render base es salta sencera; el + // jugador no apareix al z-sort tot i seguir vivint al vector. + if (state_ == State::EXITED) { return; } + // Mentre el jugador està enlaire, el sprite es desplaça visualment // cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera // de render) abans de cridar el render base, i la restaurem després @@ -1010,6 +1030,19 @@ void Player::clampToPlayfield() { setPosY(std::clamp(getPosY(), min_y, MAX_Y)); } +void Player::respawn(float x, float y) { + state_ = State::GROUNDED; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + being_pushed_ = false; + jump_z_ = 0.0F; + jump_vz_ = 0.0F; + landing_stun_remaining_ms_ = 0.0F; + turning_remaining_ms_ = 0.0F; + setPosX(x); + setPosY(y); +} + auto Player::getBaseBox() const -> SDL_FRect { return { .x = getPosX() + static_cast(collision_x_offset), diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index b5ebc55..a36b341 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -44,6 +44,12 @@ class Player : public AnimatedSprite { AIRBORNE, LANDING_STUN, HURT, + // Jugador que ha eixit per la porta de la sala. Despawn lògic: + // no es renderitza, no processa input, no es mou, no col·lisiona + // amb res ni rep contacte d'entitats. Idempotent: les crides + // posteriors a `exit()` són no-op. El reset el porta `respawn()` + // (cridada per GameScene en transició OUTRO → INTRO). + EXITED, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -177,12 +183,35 @@ class Player : public AnimatedSprite { [[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; } [[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; } + [[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; } // Tangible per a contactes jugador↔jugador: només els estats a terra // amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen). + // EXITED tampoc — el jugador ha eixit lògicament i ja no és al món. [[nodiscard]] auto isPvpTangible() const -> bool { return state_ == State::GROUNDED || state_ == State::LANDING_STUN; } + // 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 + // la fa el caller (típicament `Door::onPlayerContact`) per + // mantenir aquesta capçalera lleugera (sense incloure event_bus). + // Definit inline perquè els consumidors de baix nivell (test + // d'entitats) puguen enllaçar Door sense arrossegar player.cpp. + void exit() { + if (state_ == State::EXITED) { return; } + state_ = State::EXITED; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + being_pushed_ = false; + } + + // 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 + // (es pot cridar des de qualsevol estat). + void respawn(float x, float y); + // Alçada actual del salt (píxels visuals per damunt del sòl). // 0 quan està a terra. L'usa el render i el sistema de col·lisions // per altura. diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 8541ea5..abaeac3 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -7,7 +7,9 @@ // T4 sarcòfag — perímetre computat correctament; notifyTilePainted // passa a OPENING quan es visiten tots els tiles. // T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta -// s'obri sola; contactar la porta OPEN emet `level_complete`. +// s'obri sola. El contacte de la porta amb un jugador +// nul·l és no-op segur (la porta ja no emet `level_complete` +// directament; ho fa EntityManager comptant `player_exited`). // T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1. // T7 plantilles — carrega els 7 YAML d'entitat sense excepcions. // T8 decoration — Torch no col·lisiona ni és interactable. @@ -173,12 +175,6 @@ namespace { manager.add(std::move(door)); manager.setDoor(door_ptr); - // Subscriptor local per capturar `level_complete`. - bool level_complete_fired = false; - manager.getBus().subscribe("level_complete", [&](const Event&) { - level_complete_fired = true; - }); - // Forcem l'obertura dels dos sarcs visitant els seus perímetres. auto forceOpen = [](Sarcophagus* s) { const int COL0 = s->getTilePos().col; @@ -206,12 +202,16 @@ namespace { REQUIRE(manager.openedSarcophagusCount() == 2); REQUIRE(door_ptr->getState() == Door::State::OPEN); - // Contacte amb la porta oberta → level_complete. - door_ptr->onPlayerContact(nullptr); - REQUIRE(level_complete_fired); - - // Un segon contacte NO torna a disparar (idempotent). - level_complete_fired = false; + // Contacte sense jugador (nullptr): no-op segur. La porta ja no + // emet `level_complete` directament — ara és EntityManager qui + // l'emet quan tots els jugadors han eixit. La cobertura + // d'aquest agregat viu al test específic d'EntityManager + // (test_game_fsm) perquè requereix instanciar Player, fora + // d'abast d'aquest smoke purament d'entitats. + bool level_complete_fired = false; + manager.getBus().subscribe("level_complete", [&](const Event&) { + level_complete_fired = true; + }); door_ptr->onPlayerContact(nullptr); REQUIRE(!level_complete_fired); }