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) {
|
||||
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,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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user