diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index fba6b3e..290a9b3 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -65,7 +65,7 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); 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 // damunt de l'alçada del cos de l'entitat, no hi ha contacte. // És el mecanisme del salt per esquivar momies, roques, etc. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index fdc2481..bb5e14a 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -328,10 +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; } + // EXITED/DISABLED: jugador fora del joc. No xoca amb res ni res + // xoca amb ell. També es filtren els altres jugadors fora de joc + // al check actor↔actor més avall via el predicat `isOutOfPlay`. + if (isOutOfPlay()) { 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: @@ -357,7 +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 (p->isOutOfPlay()) { 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 @@ -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. // 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; } + // EXITED/DISABLED: jugador fora del joc, 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 + // que GameScene el reactiva (respawn / spawnFromAir). + if (isOutOfPlay()) { 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 @@ -719,6 +720,9 @@ void Player::resolveLandingOverlap() { if (all_players_ == nullptr) { return; } for (Player* other : *all_players_) { 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. // Coherent amb la resta de col·lisions actor-actor del joc: // momies, fuetades futures, resolver PvP… tots usen hit-box. Un @@ -753,8 +757,8 @@ 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; } + // EXITED/DISABLED: cap moviment ni col·lisió. Idèntic al gating de `tick`. + if (isOutOfPlay()) { 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 @@ -875,9 +879,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; } + // EXITED/DISABLED: invisible. La crida al render base es salta + // sencera; el jugador no apareix al z-sort tot i seguir vivint al + // vector. + if (isOutOfPlay()) { return; } // Mentre el jugador està enlaire, el sprite es desplaça visualment // 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); } +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(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 { return { .x = getPosX() + static_cast(collision_x_offset), @@ -1144,6 +1197,7 @@ auto Player::tryPushSnapBlockers( const SDL_FRect MY_HIT = getHitBox(); for (Player* p : *all_players_) { if (p == nullptr || p == this) { continue; } + if (p->isOutOfPlay()) { continue; } const SDL_FRect OTHER_HIT = p->getHitBox(); if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) { blockers.push_back(p); diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index c8c3bfb..cd57274 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -50,6 +50,14 @@ class Player : public AnimatedSprite { // posteriors a `exit()` són no-op. El reset el porta `respawn()` // (cridada per GameScene en transició OUTRO → INTRO). 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`). @@ -184,6 +192,15 @@ class Player : public AnimatedSprite { [[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; } + [[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 // 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. @@ -216,9 +233,32 @@ class Player : public AnimatedSprite { // 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). + // (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); + // 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). // 0 quan està a terra. L'usa el render i el sistema de col·lisions // per altura. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 04dd9df..425dda2 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -113,12 +113,12 @@ namespace { // surt projectat sense control i els dos es travessen. Coincideix // amb la regla d'`Player::collidesWithWorld`. if (a->isHurt() || b->isHurt()) { return; } - // EXITED: el jugador despawnat no és tangible. Si entrés al - // resolver, la seua presència física frenaria l'altre (per + // EXITED/DISABLED: jugadors fora del joc, no participen al resolver. + // 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 - // d'aquí garanteix que el segon jugador pot creuar la porta - // sense interferència del primer ja eixit. - if (a->isExited() || b->isExited()) { return; } + // d'aquí garanteix que el jugador actiu pot creuar la porta o + // moure's lliurement sense interferència d'un slot inactiu. + if (a->isOutOfPlay() || b->isOutOfPlay()) { return; } // Aire ↔ tierra: el saltador es travessa net del caminant. Sense // 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_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); + players_starts_active_ = {USE_P1, USE_P2}; - std::string anim_p1; - std::string anim_p2; - if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); } - if (USE_P2) { anim_p2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); } + // Carreguem assets per als DOS jugadors sempre, encara que algun no + // arranque actiu: l'slot existeix igualment al vector com a DISABLED + // i podria reactivar-se amb un futur "join in progress". El cost de + // 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 // del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar // Player perquè el spawn consulta `collision_*`. Els membres de Player - // són `static inline`, compartits per tots els jugadors: cridem - // `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos - // personatges usen la mateixa geometria (morcus és un repaletat de sam). - Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_yaml); + // són `static inline`, compartits per tots els jugadors; carregar des + // del YAML de P1 és suficient (els dos personatges comparteixen + // geometria — morcus és un repaletat de sam). + Player::loadFrom(config_.player_yaml); // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // 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 // moga automàticament l'spawn — no hi ha cap valor hardcoded. 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 // (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no @@ -331,16 +334,29 @@ GameScene::GameScene(Config config) // puntuació (veure `Event::player_id`). constexpr int ID_P1 = 0; constexpr int ID_P2 = 1; - if (USE_P1) { - const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); - const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast(Map::tile_size); - players_.emplace_back(std::make_unique(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 float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast(Map::tile_size); - players_.emplace_back(std::make_unique(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); + const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); + const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); + + // 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(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(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); + players_.emplace_back(std::make_unique(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 // `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_); } // Comptador d'EXITED a EntityManager: publica `level_complete` quan - // tots els jugadors actius han travessat la porta. Cap setExpectedPlayerCount - // ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí - // perquè ja sabem el nombre real d'instàncies. - entity_manager_.setExpectedPlayerCount(static_cast(players_.size())); + // tots els jugadors *actius* han travessat la porta. Els slots DISABLED + // permanents (single-player) NO compten — d'altra manera la sala mai + // es completaria, perquè un slot DISABLED no pot eixir per la porta. + 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: // passem el mateix factor perquè vaja al mateix ritme que el resolver. 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. Map::loadFrom(config_.map_yaml); entity_manager_.reload(config_.map_yaml); - entity_manager_.setExpectedPlayerCount(static_cast(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(); } - // Respawn dels jugadors. Recalcular spawn per si door_col canvia - // (avui el mapa no canvia entre cicles, però la lògica ja queda - // preparada per a l'itinerari futur). - const auto SPAWN = computeSpawn(); - const bool BOTH = (players_.size() >= 2); - // Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x + - // tile_size, perquè SPAWN.p1_x és el centre menys un tile). - const float SINGLE_X = SPAWN.p1_x + static_cast(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); - } + // Tornem tots els slots a DISABLED. Els actius els reactivarà el + // trigger d'spawn aeri dins de `updateRoomTransition` quan torni a + // disparar-se el delay del nou cicle d'INTRO. Així cada cicle de + // sala repeteix la cinemàtica de caiguda de la mateixa manera que + // el primer. + for (auto& p : players_) { p->disable(); } 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(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) { @@ -471,6 +507,12 @@ void GameScene::setState(RoomFsm::State new_state) { const auto PREV = state_; state_ = new_state; 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 // "torna a la mateixa sala"), reseteja completament l'estat del // 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) { + // 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); if (NEXT != state_) { setState(NEXT); } } @@ -725,29 +778,27 @@ void GameScene::update(int delta_ms) { constexpr bool EDITOR_FROZEN = false; #endif const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); - const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING); if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(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 - // resolució de contactes jugador↔jugador entre input i posició: - // 1) tick: cada jugador calcula la velocitat intencionada - // (teclat + mando + facing). - // 2) resolve: si els dos es trobarien, anul·lem velocitats - // (head-on) o escalem a push_factor (empenta). - // 3) applyMovement: en l'ordre "pushed primer" perquè quan el - // pusher faça `tryMove`, el pushed ja estiga fora de la - // seua caixa. La callback també tracta l'altre jugador com - // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. - for (auto& player : players_) { player->tick(DT); } - resolvePlayerVsPlayer(players_view_, DT, push_factor_); - for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } - } + // Pipeline de moviment en tres passes per poder intercalar la + // resolució de contactes jugador↔jugador entre input i posició: + // 1) tick: cada jugador calcula la velocitat intencionada + // (teclat + mando + facing). + // 2) resolve: si els dos es trobarien, anul·lem velocitats + // (head-on) o escalem a push_factor (empenta). + // 3) applyMovement: en l'ordre "pushed primer" perquè quan el + // pusher faça `tryMove`, el pushed ja estiga fora de la + // seua caixa. La callback també tracta l'altre jugador com + // 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); } + resolvePlayerVsPlayer(players_view_, DT, push_factor_); + for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } // 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 @@ -755,12 +806,11 @@ void GameScene::update(int delta_ms) { // aplicar la transició OPENING → OPEN en el mateix frame. entity_manager_.update(DT); - // Detecció de contacte player↔entitat després que totes les entitats - // hagin mogut aquest frame. Reutilitzem la vista plana que ja - // manté el GameScene per al resolver player↔player. - if (GAMEPLAY_ACTIVE) { - entity_manager_.checkPlayerCollisions(players_view_); - } + // Detecció de contacte player↔entitat. `checkPlayerCollisions` ja + // filtra jugadors fora del joc (`isOutOfPlay`), així que durant + // l'INTRO previ a l'spawn no es disparen contactes amb les + // entitats que ja s'animen al fons. + entity_manager_.checkPlayerCollisions(players_view_); } #ifdef _DEBUG @@ -799,19 +849,14 @@ void GameScene::render() { // explícita: l'editor edita el tilemap, no la capa de pintat). if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } 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 - // pintat (al damunt) però per darrere de TOTS els actors — - // entitats i jugadors, inclòs el saltador mateix— així la sombra - // queda al sòl sense tapar res. `renderShadow` és no-op si el - // jugador està a terra, així que no cal filtrar al callsite. - for (const auto& p : players_) { p->renderShadow(); } - } + // Passada de sombres dels jugadors saltant. Va per davant del + // pintat (al damunt) però per darrere de TOTS els actors — + // entitats i jugadors, inclòs el saltador mateix— així la sombra + // queda al sòl sense tapar res. `renderShadow` és no-op si el + // 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(); } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // 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 @@ -822,16 +867,18 @@ void GameScene::render() { Player* player; // nullable; si és nullptr, s'usa `entity`. }; std::vector order; - order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0)); + order.reserve(entity_manager_.entityCount() + players_.size()); const auto ENTITIES = entity_manager_.getEntities(); std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; }); - if (RENDER_PLAYERS) { - std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { - return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; - }); - } + // 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) { + return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; + }); std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { return a.feet_y < b.feet_y; }); diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index b8f1149..e588cf7 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -84,8 +85,34 @@ class GameScene : public Scene { // Vector des del principi: AEE suportarà multijugador local; cada Player // 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> 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 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 // 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 diff --git a/source/game/scenes/room_fsm.cpp b/source/game/scenes/room_fsm.cpp index cbc1614..1016108 100644 --- a/source/game/scenes/room_fsm.cpp +++ b/source/game/scenes/room_fsm.cpp @@ -42,6 +42,7 @@ namespace RoomFsm { 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_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")) { const auto& n = yaml["room_outro"]; diff --git a/source/game/scenes/room_fsm.hpp b/source/game/scenes/room_fsm.hpp index 5b72a07..0e357c5 100644 --- a/source/game/scenes/room_fsm.hpp +++ b/source/game/scenes/room_fsm.hpp @@ -42,6 +42,13 @@ namespace RoomFsm { int outro_text_exit_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_exit_curve{nullptr}; }; diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index d52ca21..356380e 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — 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 GameCanvas {