feat: estat DISABLED i spawn aeri al començar la sala — v0.25.0

This commit is contained in:
JailDesigner committed 2026-04-26 14:17:17 +02:00
1 parent 645db217fa
commit 580c13e854
8 files changed
+256 -80

No files matched your search

@@ -65,7 +65,7 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
if (p == nullptr || p->isExited()) { continue; } if (p == nullptr || p->isOutOfPlay()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per // Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte. // damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc. // És el mecanisme del salt per esquivar momies, roques, etc.
+68 -14
View File
@@ -328,10 +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. // EXITED/DISABLED: jugador fora del joc. No xoca amb res ni res
// També salta jugadors EXITED del check actor↔actor més avall via // xoca amb ell. També es filtren els altres jugadors fora de joc
// el filtre de `isExited` (vegeu el predicat any_of). // al check actor↔actor més avall via el predicat `isOutOfPlay`.
if (state_ == State::EXITED) { return false; } if (isOutOfPlay()) { 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:
@@ -357,7 +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 (p->isOutOfPlay()) { 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
@@ -407,10 +407,11 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot
// llegibilitat. La complexitat puja 1 per la guarda EXITED. // llegibilitat. La complexitat puja 1 per la guarda EXITED.
// NOLINTNEXTLINE(readability-function-cognitive-complexity) // 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 // EXITED/DISABLED: jugador fora del joc, no fa res. Cap input, cap
// bus event. Continua sent al vector de jugadors per simplicitat // física, cap bus event. Continua sent al vector de jugadors per
// (no s'esborra) però queda completament inert fins al respawn. // simplicitat (no s'esborra) però queda completament inert fins
if (state_ == State::EXITED) { return; } // que GameScene el reactiva (respawn / spawnFromAir).
if (isOutOfPlay()) { 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
@@ -719,6 +720,9 @@ void Player::resolveLandingOverlap() {
if (all_players_ == nullptr) { return; } if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) { for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; } if (other == nullptr || other == this) { continue; }
// Salta jugadors fora del joc: un caigut sobre un slot DISABLED
// (o sobre un EXITED encara al vector) no els ha de projectar.
if (other->isOutOfPlay()) { continue; }
// Test amb la hit-box (8×8 de contacte precís), no la base-box. // Test amb la hit-box (8×8 de contacte precís), no la base-box.
// Coherent amb la resta de col·lisions actor-actor del joc: // Coherent amb la resta de col·lisions actor-actor del joc:
// momies, fuetades futures, resolver PvP… tots usen hit-box. Un // momies, fuetades futures, resolver PvP… tots usen hit-box. Un
@@ -753,8 +757,8 @@ 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`. // EXITED/DISABLED: cap moviment ni col·lisió. Idèntic al gating de `tick`.
if (state_ == State::EXITED) { return; } if (isOutOfPlay()) { 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
@@ -875,9 +879,10 @@ void Player::publishTileEnteredIfChanged() {
} }
void Player::render() { void Player::render() {
// EXITED: invisible. La crida al render base es salta sencera; el // EXITED/DISABLED: invisible. La crida al render base es salta
// jugador no apareix al z-sort tot i seguir vivint al vector. // sencera; el jugador no apareix al z-sort tot i seguir vivint al
if (state_ == State::EXITED) { return; } // vector.
if (isOutOfPlay()) { 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
@@ -1054,6 +1059,54 @@ void Player::respawn(float x, float y) {
setPosY(y); setPosY(y);
} }
void Player::disable() {
if (state_ == State::DISABLED) { return; }
state_ = State::DISABLED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_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;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
last_input_cardinal_ = Cardinal::NONE;
}
void Player::spawnFromAir(float x, float y) {
// Net total: equival a un `respawn` seguit d'una entrada en l'aire.
// Així el flag de gir, l'animació, els flancs del salt i les
// velocitats residuals queden a un estat conegut abans de promocionar
// a AIRBORNE. Si hi havia un cardinal d'input penjat de la sessió
// anterior, no contamina la pròxima caiguda.
respawn(x, y);
// Altura derivada de la geometria: prou per arrencar amb el sprite
// sencer per damunt del bord superior del canvas (recordem que
// visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge
// perquè el primer frame ja siga clarament off-screen, no just al ras.
constexpr float OFFSCREEN_MARGIN_PX = 8.0F;
const float SPAWN_HEIGHT = y + static_cast<float>(sprite_h) + OFFSCREEN_MARGIN_PX;
state_ = State::AIRBORNE;
jump_z_ = SPAWN_HEIGHT;
// Caiguda recta: sense impuls inicial, deixem que la gravetat (la
// mateixa que la del salt normal) faça la feina. Velocitat horitzontal
// a zero perquè no es desviï del passadís d'spawn.
jump_vz_ = 0.0F;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
// Mirant cap avall durant la caiguda — `animationName(AIRBORNE)` amb
// facing DOWN ja resol a `jump_down`. `respawn` ja ha posat facing a
// DOWN; ho deixem explícit ací per claredat de la cinemàtica.
facing_ = Facing::DOWN;
setCurrentAnimation("jump_down");
}
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),
@@ -1144,6 +1197,7 @@ auto Player::tryPushSnapBlockers(
const SDL_FRect MY_HIT = getHitBox(); const SDL_FRect MY_HIT = getHitBox();
for (Player* p : *all_players_) { for (Player* p : *all_players_) {
if (p == nullptr || p == this) { continue; } if (p == nullptr || p == this) { continue; }
if (p->isOutOfPlay()) { continue; }
const SDL_FRect OTHER_HIT = p->getHitBox(); const SDL_FRect OTHER_HIT = p->getHitBox();
if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) { if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) {
blockers.push_back(p); blockers.push_back(p);
+41 -1
View File
@@ -50,6 +50,14 @@ class Player : public AnimatedSprite {
// posteriors a `exit()` són no-op. El reset el porta `respawn()` // posteriors a `exit()` són no-op. El reset el porta `respawn()`
// (cridada per GameScene en transició OUTRO → INTRO). // (cridada per GameScene en transició OUTRO → INTRO).
EXITED, EXITED,
// Slot de jugador inactiu: instanciat però fora de partida. Mateix
// perfil físic que EXITED (invisible, sense input, sense col·lisió,
// ignorat pel resolver PvP), però amb semàntica diferent: no
// compta com a "ha completat la sala" ni dispara `level_complete`.
// Usat per (a) el slot del segon jugador en single-player, (b)
// tots els slots durant les transicions de sala abans del spawn
// aeri, i (c) en el futur, l'estat post-mort abans del respawn.
DISABLED,
}; };
// 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`).
@@ -184,6 +192,15 @@ class Player : public AnimatedSprite {
[[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; } [[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; }
[[nodiscard]] auto isDisabled() const -> bool { return state_ == State::DISABLED; }
// Resum dels estats "fora del món": EXITED i DISABLED comparteixen el
// mateix perfil físic (no es pinten, no llegeixen input, no
// col·lisionen, no participen al resolver PvP). Tots dos gates de
// l'update i el render comproven aquesta combinació amb una sola
// crida per evitar oblits si demà afegim un tercer estat-fora-del-món.
[[nodiscard]] auto isOutOfPlay() const -> bool {
return state_ == State::EXITED || state_ == State::DISABLED;
}
// 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. // EXITED tampoc — el jugador ha eixit lògicament i ja no és al món.
@@ -216,9 +233,32 @@ class Player : public AnimatedSprite {
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0, // Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat // estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent // per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
// (es pot cridar des de qualsevol estat). // (es pot cridar des de qualsevol estat). Es manté disponible per a
// qualsevol cas futur on calgui aparèixer el jugador ja a terra
// sense la cinemàtica de caiguda; el flux estàndard d'arrencada de
// sala usa `disable()` + `spawnFromAir()`.
void respawn(float x, float y); void respawn(float x, float y);
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
// segona crida és no-op. Mateix patró que `exit()` per simetria.
// No publica res al bus — el GameScene decideix quan disparar el
// `spawnFromAir` posterior, i la transició DISABLED↔jugant és
// ortogonal a `level_complete`. Útil per:
// - el slot del segon jugador en single-player,
// - el període entre setState(ROOM_INTRO) i el trigger del spawn,
// - el futur estat post-mort (abans del respawn temporitzat).
void disable();
// Cinemàtica d'aparició: posiciona el jugador al seu tile final
// (x, y al sistema de coordenades del sprite) i el deixa en
// AIRBORNE a una altura derivada de la geometria del playfield —
// prou per arrencar fora del marc i caure recte fins al sòl. La
// velocitat horitzontal queda a zero (caiguda perfectament
// vertical). En tocar terra, el flux normal d'AIRBORNE→
// LANDING_STUN→GROUNDED el reactiva amb input. Reutilitzable per
// al respawn post-mort: mateixa entrada cinemàtica.
void spawnFromAir(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.
+110 -63
View File
@@ -113,12 +113,12 @@ namespace {
// surt projectat sense control i els dos es travessen. Coincideix // surt projectat sense control i els dos es travessen. Coincideix
// amb la regla d'`Player::collidesWithWorld`. // amb la regla d'`Player::collidesWithWorld`.
if (a->isHurt() || b->isHurt()) { return; } if (a->isHurt() || b->isHurt()) { return; }
// EXITED: el jugador despawnat no és tangible. Si entrés al // EXITED/DISABLED: jugadors fora del joc, no participen al resolver.
// resolver, la seua presència física frenaria l'altre (per // Si hi entrés un, la seua presència física frenaria l'altre (per
// overlap predictiu) tot i que `applyMovement` és no-op. Sortir // overlap predictiu) tot i que `applyMovement` és no-op. Sortir
// d'aquí garanteix que el segon jugador pot creuar la porta // d'aquí garanteix que el jugador actiu pot creuar la porta o
// sense interferència del primer ja eixit. // moure's lliurement sense interferència d'un slot inactiu.
if (a->isExited() || b->isExited()) { return; } if (a->isOutOfPlay() || b->isOutOfPlay()) { return; }
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense // Aire ↔ tierra: el saltador es travessa net del caminant. Sense
// resolver, sense head-on, sense push. Els dos continuen amb la // resolver, sense head-on, sense push. Els dos continuen amb la
@@ -250,19 +250,22 @@ GameScene::GameScene(Config config)
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
players_starts_active_ = {USE_P1, USE_P2};
std::string anim_p1; // Carreguem assets per als DOS jugadors sempre, encara que algun no
std::string anim_p2; // arranque actiu: l'slot existeix igualment al vector com a DISABLED
if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); } // i podria reactivar-se amb un futur "join in progress". El cost de
if (USE_P2) { anim_p2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); } // tenir el sprite cachejat sense usar-se és negligible (cap render).
const std::string ANIM_P1 = load_player_assets(config_.player_sprite, config_.player_yaml);
const std::string ANIM_P2 = load_player_assets(config_.player2_sprite, config_.player2_yaml);
// Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada // Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada
// del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar // del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar
// Player perquè el spawn consulta `collision_*`. Els membres de Player // Player perquè el spawn consulta `collision_*`. Els membres de Player
// són `static inline`, compartits per tots els jugadors: cridem // són `static inline`, compartits per tots els jugadors; carregar des
// `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos // del YAML de P1 és suficient (els dos personatges comparteixen
// personatges usen la mateixa geometria (morcus és un repaletat de sam). // geometria — morcus és un repaletat de sam).
Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_yaml); Player::loadFrom(config_.player_yaml);
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
@@ -321,7 +324,7 @@ GameScene::GameScene(Config config)
// `Map::door_col` perquè canviar la columna de la porta al YAML // `Map::door_col` perquè canviar la columna de la porta al YAML
// moga automàticament l'spawn — no hi ha cap valor hardcoded. // moga automàticament l'spawn — no hi ha cap valor hardcoded.
const auto SPAWN = computeSpawn(); const auto SPAWN = computeSpawn();
const bool BOTH = (USE_P1 && USE_P2); const bool BOTH_ACTIVE = (USE_P1 && USE_P2);
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa // Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no // (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
@@ -331,16 +334,29 @@ GameScene::GameScene(Config config)
// puntuació (veure `Event::player_id`). // puntuació (veure `Event::player_id`).
constexpr int ID_P1 = 0; constexpr int ID_P1 = 0;
constexpr int ID_P2 = 1; constexpr int ID_P2 = 1;
if (USE_P1) {
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, X, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
}
if (USE_P2) {
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
const float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); // Sempre instanciem els dos slots. Mode single-player: l'únic actiu
// queda centrat sota la porta (= SPAWN.p1_x + tile_size); l'inactiu
// ocupa la posició BOTH per simetria, però com es queda DISABLED tot
// el cicle no es nota. Així `players_[0]` sempre és P1 i `players_[1]`
// sempre és P2, independentment de quins juguen.
const auto TILE_F = static_cast<float>(Map::tile_size);
float p1_x = SPAWN.p1_x;
float p2_x = SPAWN.p2_x;
if (!BOTH_ACTIVE) {
if (USE_P1) { p1_x = SPAWN.p1_x + TILE_F; }
if (USE_P2) { p2_x = SPAWN.p2_x - TILE_F; }
} }
players_.emplace_back(std::make_unique<Player>(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
players_.emplace_back(std::make_unique<Player>(ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
// Tots els slots arrenquen DISABLED. Els actius els reactivarà
// `updateRoomTransition` quan s'esgote `player_spawn_delay_ms_` dins
// de ROOM_INTRO (cinemàtica de caiguda); els inactius es queden
// DISABLED tot el cicle de sala.
for (auto& p : players_) { p->disable(); }
// PaintLayer: font única del pintat del nivell. Es crea després de // PaintLayer: font única del pintat del nivell. Es crea després de
// `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags // `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags
@@ -366,10 +382,11 @@ GameScene::GameScene(Config config)
for (auto& p : players_) { p->setOtherPlayers(&players_view_); } for (auto& p : players_) { p->setOtherPlayers(&players_view_); }
// Comptador d'EXITED a EntityManager: publica `level_complete` quan // Comptador d'EXITED a EntityManager: publica `level_complete` quan
// tots els jugadors actius han travessat la porta. Cap setExpectedPlayerCount // tots els jugadors *actius* han travessat la porta. Els slots DISABLED
// ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí // permanents (single-player) NO compten — d'altra manera la sala mai
// perquè ja sabem el nombre real d'instàncies. // es completaria, perquè un slot DISABLED no pot eixir per la porta.
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size())); const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
// El snap també empeny lateralment si l'altre jugador ocupa el carril: // El snap també empeny lateralment si l'altre jugador ocupa el carril:
// passem el mateix factor perquè vaja al mateix ritme que el resolver. // passem el mateix factor perquè vaja al mateix ritme que el resolver.
for (auto& p : players_) { p->setPushFactor(push_factor_); } for (auto& p : players_) { p->setPushFactor(push_factor_); }
@@ -444,25 +461,44 @@ void GameScene::resetRoom() {
// CLOSED amb visited buit, momies i altres actors a la posició inicial. // CLOSED amb visited buit, momies i altres actors a la posició inicial.
Map::loadFrom(config_.map_yaml); Map::loadFrom(config_.map_yaml);
entity_manager_.reload(config_.map_yaml); entity_manager_.reload(config_.map_yaml);
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size())); // Tornem a fixar el comptador de "esperats" amb només els slots
// actius — un slot DISABLED permanent (single-player) no compta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
if (paint_layer_) { paint_layer_->clear(); } if (paint_layer_) { paint_layer_->clear(); }
// Respawn dels jugadors. Recalcular spawn per si door_col canvia // Tornem tots els slots a DISABLED. Els actius els reactivarà el
// (avui el mapa no canvia entre cicles, però la lògica ja queda // trigger d'spawn aeri dins de `updateRoomTransition` quan torni a
// preparada per a l'itinerari futur). // disparar-se el delay del nou cicle d'INTRO. Així cada cicle de
const auto SPAWN = computeSpawn(); // sala repeteix la cinemàtica de caiguda de la mateixa manera que
const bool BOTH = (players_.size() >= 2); // el primer.
// Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x + for (auto& p : players_) { p->disable(); }
// tile_size, perquè SPAWN.p1_x és el centre menys un tile).
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
float x = SINGLE_X;
if (BOTH) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->respawn(x, SPAWN.y);
}
std::cout << "[ROOM] reset: entities reloaded, paint cleared, " std::cout << "[ROOM] reset: entities reloaded, paint cleared, "
<< players_.size() << " player(s) respawned\n"; << ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
}
void GameScene::triggerActivePlayerSpawn() {
if (players_spawned_in_intro_) { return; }
players_spawned_in_intro_ = true;
// Recalcula spawn per si door_col canvia entre sales (avui no, però
// la lògica ja queda preparada per a l'itinerari).
const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
// Mode single-player: l'únic actiu queda centrat sota la porta. Els
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
// amb un sol actiu, el reposicionem al centre exacte.
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->spawnFromAir(x, SPAWN.y);
}
std::cout << "[ROOM] spawn cinematic: airborne entry for active players\n";
} }
void GameScene::setState(RoomFsm::State new_state) { void GameScene::setState(RoomFsm::State new_state) {
@@ -471,6 +507,12 @@ void GameScene::setState(RoomFsm::State new_state) {
const auto PREV = state_; const auto PREV = state_;
state_ = new_state; state_ = new_state;
transition_ = {}; transition_ = {};
// En entrar a un nou cicle d'INTRO, resetegem el timer i el flag de
// l'spawn aeri perquè es torne a disparar al delay configurat.
if (new_state == RoomFsm::State::ROOM_INTRO) {
room_intro_elapsed_ms_ = 0;
players_spawned_in_intro_ = false;
}
// En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de // En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de
// "torna a la mateixa sala"), reseteja completament l'estat del // "torna a la mateixa sala"), reseteja completament l'estat del
// nivell. Si la transició és la inicial (PREV també és ROOM_INTRO, // nivell. Si la transició és la inicial (PREV també és ROOM_INTRO,
@@ -481,6 +523,17 @@ void GameScene::setState(RoomFsm::State new_state) {
} }
void GameScene::updateRoomTransition(int delta_ms) { void GameScene::updateRoomTransition(int delta_ms) {
// Disparador d'spawn aeri: timer propi (independent de les fases de
// la FSM) que arrenca a l'entrada de ROOM_INTRO. La separació deixa
// el delay desacoblat del fade-in i, demà, reutilitzable per al
// respawn post-mort sense que la FSM de sala hi haja d'intervenir.
if (state_ == RoomFsm::State::ROOM_INTRO) {
room_intro_elapsed_ms_ += delta_ms;
if (!players_spawned_in_intro_ && room_intro_elapsed_ms_ >= transition_config_.player_spawn_delay_ms) {
triggerActivePlayerSpawn();
}
}
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
if (NEXT != state_) { setState(NEXT); } if (NEXT != state_) { setState(NEXT); }
} }
@@ -725,15 +778,9 @@ void GameScene::update(int delta_ms) {
constexpr bool EDITOR_FROZEN = false; constexpr bool EDITOR_FROZEN = false;
#endif #endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale); const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F; const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
// poden col·lisionar amb entitats. Les entitats sí, perquè el
// disseny demana que el món continui animant-se durant les
// transicions de sala.
if (GAMEPLAY_ACTIVE) {
// Pipeline de moviment en tres passes per poder intercalar la // Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició: // resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada // 1) tick: cada jugador calcula la velocitat intencionada
@@ -744,10 +791,14 @@ void GameScene::update(int delta_ms) {
// pusher faça `tryMove`, el pushed ja estiga fora de la // pusher faça `tryMove`, el pushed ja estiga fora de la
// seua caixa. La callback també tracta l'altre jugador com // seua caixa. La callback també tracta l'altre jugador com
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud. // a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
// Cap gate per estat de sala: els jugadors fora del joc (DISABLED
// durant l'INTRO abans del trigger d'spawn, o EXITED post-porta)
// s'auto-salten a `tick`/`applyMovement`/`render`. Així la
// cinemàtica de spawn aeri pot transcorrer mentre la fase d'INTRO
// encara es mou.
for (auto& player : players_) { player->tick(DT); } for (auto& player : players_) { player->tick(DT); }
resolvePlayerVsPlayer(players_view_, DT, push_factor_); resolvePlayerVsPlayer(players_view_, DT, push_factor_);
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
}
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha // El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat // emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
@@ -755,13 +806,12 @@ void GameScene::update(int delta_ms) {
// aplicar la transició OPENING → OPEN en el mateix frame. // aplicar la transició OPENING → OPEN en el mateix frame.
entity_manager_.update(DT); entity_manager_.update(DT);
// Detecció de contacte player↔entitat després que totes les entitats // Detecció de contacte player↔entitat. `checkPlayerCollisions` ja
// hagin mogut aquest frame. Reutilitzem la vista plana que ja // filtra jugadors fora del joc (`isOutOfPlay`), així que durant
// manté el GameScene per al resolver player↔player. // l'INTRO previ a l'spawn no es disparen contactes amb les
if (GAMEPLAY_ACTIVE) { // entitats que ja s'animen al fons.
entity_manager_.checkPlayerCollisions(players_view_); entity_manager_.checkPlayerCollisions(players_view_);
} }
}
#ifdef _DEBUG #ifdef _DEBUG
editor_.update(delta_ms, tilemap_); editor_.update(delta_ms, tilemap_);
@@ -799,19 +849,14 @@ void GameScene::render() {
// explícita: l'editor edita el tilemap, no la capa de pintat). // explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) { if (!SKIP_ACTORS) {
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
// Les entitats sí que es renderitzen perquè el disseny demana que
// el món continui animant-se durant el fade i el text.
const bool RENDER_PLAYERS = (state_ == RoomFsm::State::PLAYING);
if (RENDER_PLAYERS) {
// Passada de sombres dels jugadors saltant. Va per davant del // Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors — // pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra // entitats i jugadors, inclòs el saltador mateix— així la sombra
// queda al sòl sense tapar res. `renderShadow` és no-op si el // queda al sòl sense tapar res. `renderShadow` és no-op si el
// jugador està a terra, així que no cal filtrar al callsite. // jugador no està a l'aire (i, com `render()`, també no-op si
// `isOutOfPlay`). Cap gate per estat de sala: durant la
// cinemàtica d'spawn aeri ja volem que la sombra es vegi.
for (const auto& p : players_) { p->renderShadow(); } for (const auto& p : players_) { p->renderShadow(); }
}
// Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// els actors amb els peus més amunt queden dibuixats primer (al fons), // els actors amb els peus més amunt queden dibuixats primer (al fons),
// els més avall al davant. Així un sarc situat per darrere d'un player // els més avall al davant. Així un sarc situat per darrere d'un player
@@ -822,16 +867,18 @@ void GameScene::render() {
Player* player; // nullable; si és nullptr, s'usa `entity`. Player* player; // nullable; si és nullptr, s'usa `entity`.
}; };
std::vector<Renderable> order; std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0)); order.reserve(entity_manager_.entityCount() + players_.size());
const auto ENTITIES = entity_manager_.getEntities(); const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
}); });
if (RENDER_PLAYERS) { // Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
// es pinten amb el seu feet_y normal. La sombra que afecta la
// sombra del salt s'ha pintat ja a la passada anterior.
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
}); });
}
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y; return a.feet_y < b.feet_y;
}); });
+27
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <array>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -84,8 +85,34 @@ class GameScene : public Scene {
// Vector des del principi: AEE suportarà multijugador local; cada Player // Vector des del principi: AEE suportarà multijugador local; cada Player
// té els seus propis Controls i entra al vector amb una crida extra. // té els seus propis Controls i entra al vector amb una crida extra.
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
// com en single-player: l'slot inactiu es queda en estat DISABLED de
// manera permanent, deixant el camí preparat per al "join in progress"
// futur (el segon jugador podrà entrar a meitat de partida sense
// recrear estructures). Veure `players_starts_active_`.
std::vector<std::unique_ptr<Player>> players_; std::vector<std::unique_ptr<Player>> players_;
// Quins slots juguen aquesta partida segons `config_.starting_players`.
// Index = id del jugador (0 = P1, 1 = P2). Determina a qui aplica el
// spawn aeri al final de la fase 0 d'INTRO; un slot amb `false` es
// queda DISABLED tot el cicle de sala.
std::array<bool, 2> players_starts_active_{false, false};
// Mil·lisegons des de l'entrada en ROOM_INTRO, per disparar la
// cinemàtica de spawn aeri quan s'arriba a
// `transition_config_.player_spawn_delay_ms`. Es reseteja a
// `setState(ROOM_INTRO)`. `players_spawned_in_intro_` garanteix
// que el trigger només es dispara una vegada per cicle.
int room_intro_elapsed_ms_{0};
bool players_spawned_in_intro_{false};
// Cinemàtica de spawn: posa els slots actius en AIRBORNE des d'una
// altura fora de pantalla. Cridat per `updateRoomTransition` quan
// `room_intro_elapsed_ms_` supera el delay configurat. Idempotent
// (consulta `players_spawned_in_intro_`). També és el punt al qual
// es connectarà el respawn post-mort en el futur.
void triggerActivePlayerSpawn();
// Vista plana de punters crus — la fa servir Player com a llista // Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol // d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es // cop al ctor (els punters són estables perquè `players_` no es
+1
View File
@@ -42,6 +42,7 @@ namespace RoomFsm {
cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms); cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms);
cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms); cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms);
cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms); cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms);
cfg.player_spawn_delay_ms = readChildInt(n, "player_spawn_delay_ms", cfg.player_spawn_delay_ms);
} }
if (yaml.contains("room_outro")) { if (yaml.contains("room_outro")) {
const auto& n = yaml["room_outro"]; const auto& n = yaml["room_outro"];
+7
View File
@@ -42,6 +42,13 @@ namespace RoomFsm {
int outro_text_exit_ms{500}; int outro_text_exit_ms{500};
int outro_fade_out_ms{500}; int outro_fade_out_ms{500};
// Mil·lisegons des de l'inici de ROOM_INTRO en què cau la
// cinemàtica d'spawn aeri dels jugadors actius. Independent del
// fade-in perquè el mateix valor servirà per al respawn post-mort
// (que no té fade que el sincronitze). 500 ms = típicament el
// moment en què el fade-in acaba i el text encara no ha entrat.
int player_spawn_delay_ms{500};
UiConfig::CurveFn text_enter_curve{nullptr}; UiConfig::CurveFn text_enter_curve{nullptr};
UiConfig::CurveFn text_exit_curve{nullptr}; UiConfig::CurveFn text_exit_curve{nullptr};
}; };
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.24.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.25.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {