feat(player): estat EXITED i porta oberta despawna jugadors via player_exited
This commit is contained in:
1 parent
377e5d0b97
commit
5c5cd1d5da
5 files changed
+93
-22
No files matched your search
@@ -66,15 +66,22 @@ void Door::update(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Door::onPlayerContact(Player* player) {
|
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) {
|
if (bus_ != nullptr) {
|
||||||
bus_->publish(Event{
|
bus_->publish(Event{
|
||||||
.name = "level_complete",
|
.name = "player_exited",
|
||||||
.payload = std::monostate{},
|
.payload = std::monostate{},
|
||||||
.player_id = (player != nullptr) ? player->getId() : -1,
|
.player_id = player->getId(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
level_complete_emitted_ = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Door::open() {
|
void Door::open() {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
#include "game/entities/interactable.hpp"
|
#include "game/entities/interactable.hpp"
|
||||||
|
|
||||||
// Porta de sortida del nivell. Tres estats:
|
// 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 →
|
// OPENING → animació d'obertura en curs. Quan el component acaba →
|
||||||
// transita a OPEN.
|
// transita a OPEN. El passadís ja és transitable (s'allibera
|
||||||
// OPEN → accessible. Al contacte amb el jugador publica
|
// a `open()` amb `Map::door_open = true`).
|
||||||
// `level_complete` al bus (si n'hi ha).
|
// 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
|
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
|
||||||
// oberts) via `open()`. L'animació `opening` es fa servir com a
|
// oberts) via `open()`. L'animació `opening` es fa servir com a
|
||||||
@@ -38,7 +41,6 @@ class Door : public Interactable {
|
|||||||
void setAnimation(const AnimationComponent& anim);
|
void setAnimation(const AnimationComponent& anim);
|
||||||
|
|
||||||
State state_{State::CLOSED};
|
State state_{State::CLOSED};
|
||||||
bool level_complete_emitted_{false};
|
|
||||||
|
|
||||||
// Les 3 animacions precalculades al ctor des del YAML. L'activa
|
// Les 3 animacions precalculades al ctor des del YAML. L'activa
|
||||||
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
|
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
|
||||||
|
|||||||
@@ -328,6 +328,10 @@ void Player::update(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
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 (map_.collides(box)) { return true; }
|
||||||
if (all_players_ == nullptr) { return false; }
|
if (all_players_ == nullptr) { return false; }
|
||||||
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
|
// 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_;
|
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) {
|
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 == nullptr || p == this) { return false; }
|
||||||
|
if (p->isExited()) { return false; }
|
||||||
if (MY_HURT || p->isHurt()) { return false; }
|
if (MY_HURT || p->isHurt()) { return false; }
|
||||||
if (MY_AIR != p->isAirborne()) { return false; }
|
if (MY_AIR != p->isAirborne()) { return false; }
|
||||||
// Aquí els dos estats coincideixen. Si tots dos volen, el filtre
|
// 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) {
|
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
|
// 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
|
// 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
|
// (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) {
|
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_.
|
// 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
|
// El facing i intended_v* es mantenen del moment del despegue (no es
|
||||||
// recalculen). Si toquem el sòl aquest frame, transicionem a
|
// recalculen). Si toquem el sòl aquest frame, transicionem a
|
||||||
@@ -859,6 +875,10 @@ void Player::publishTileEnteredIfChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Player::render() {
|
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
|
// Mentre el jugador està enlaire, el sprite es desplaça visualment
|
||||||
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera
|
// 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
|
// 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));
|
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 {
|
auto Player::getBaseBox() const -> SDL_FRect {
|
||||||
return {
|
return {
|
||||||
.x = getPosX() + static_cast<float>(collision_x_offset),
|
.x = getPosX() + static_cast<float>(collision_x_offset),
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class Player : public AnimatedSprite {
|
|||||||
AIRBORNE,
|
AIRBORNE,
|
||||||
LANDING_STUN,
|
LANDING_STUN,
|
||||||
HURT,
|
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`).
|
// 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 getState() const -> State { return state_; }
|
||||||
[[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; }
|
[[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; }
|
||||||
[[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; }
|
[[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
|
// Tangible per a contactes jugador↔jugador: només els estats a terra
|
||||||
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
|
// 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 {
|
[[nodiscard]] auto isPvpTangible() const -> bool {
|
||||||
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
|
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).
|
// 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
|
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
|
||||||
// per altura.
|
// per altura.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
|
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
|
||||||
// passa a OPENING quan es visiten tots els tiles.
|
// passa a OPENING quan es visiten tots els tiles.
|
||||||
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
|
// 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.
|
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
|
||||||
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
|
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
|
||||||
// T8 decoration — Torch no col·lisiona ni és interactable.
|
// T8 decoration — Torch no col·lisiona ni és interactable.
|
||||||
@@ -173,12 +175,6 @@ namespace {
|
|||||||
manager.add(std::move(door));
|
manager.add(std::move(door));
|
||||||
manager.setDoor(door_ptr);
|
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.
|
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
|
||||||
auto forceOpen = [](Sarcophagus* s) {
|
auto forceOpen = [](Sarcophagus* s) {
|
||||||
const int COL0 = s->getTilePos().col;
|
const int COL0 = s->getTilePos().col;
|
||||||
@@ -206,12 +202,16 @@ namespace {
|
|||||||
REQUIRE(manager.openedSarcophagusCount() == 2);
|
REQUIRE(manager.openedSarcophagusCount() == 2);
|
||||||
REQUIRE(door_ptr->getState() == Door::State::OPEN);
|
REQUIRE(door_ptr->getState() == Door::State::OPEN);
|
||||||
|
|
||||||
// Contacte amb la porta oberta → level_complete.
|
// Contacte sense jugador (nullptr): no-op segur. La porta ja no
|
||||||
door_ptr->onPlayerContact(nullptr);
|
// emet `level_complete` directament — ara és EntityManager qui
|
||||||
REQUIRE(level_complete_fired);
|
// l'emet quan tots els jugadors han eixit. La cobertura
|
||||||
|
// d'aquest agregat viu al test específic d'EntityManager
|
||||||
// Un segon contacte NO torna a disparar (idempotent).
|
// (test_game_fsm) perquè requereix instanciar Player, fora
|
||||||
level_complete_fired = false;
|
// 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);
|
door_ptr->onPlayerContact(nullptr);
|
||||||
REQUIRE(!level_complete_fired);
|
REQUIRE(!level_complete_fired);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user