feat(player): estat EXITED i porta oberta despawna jugadors via player_exited

This commit is contained in:
JailDesigner committed 2026-04-26 10:20:13 +02:00
1 parent 377e5d0b97
commit 5c5cd1d5da
5 files changed
+93 -22

No files matched your search

+11 -4
View File
@@ -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() {
+7 -5
View File
@@ -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.
+33
View File
@@ -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<float>(collision_x_offset),
+29
View File
@@ -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.
+13 -13
View File
@@ -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);
}