diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c3759..98da76f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,6 +119,7 @@ set(APP_SOURCES source/game/scenes/intro_scene.cpp source/game/scenes/game_scene.cpp source/game/scenes/room_fsm.cpp + source/game/scenes/game_over_fsm.cpp source/game/entities/player.cpp source/game/map/map.cpp source/game/map/tilemap.cpp diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 1974030..6e4c439 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -60,6 +60,12 @@ game: room_intro_room: "Sala {r}" room_complete_line1: "Fase" room_complete_line2: "Completada" + # Rètol del game over (sub-FSM de la GameScene). El countdown es + # pinta com a segona línia (un enter) durant la fase 1 de + # CONTINUE_PROMPT — només cal traduir el rètol superior. + continue_prompt: "Continuar?" + game_over_line1: "Fi del" + game_over_line2: "Joc" menu: titles: diff --git a/data/locale/en.yaml b/data/locale/en.yaml index c2191c3..da052d4 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -59,6 +59,12 @@ game: room_intro_room: "Room {r}" room_complete_line1: "Stage" room_complete_line2: "Complete" + # Game over labels (sub-FSM in GameScene). The countdown is drawn as + # a numeric second line during CONTINUE_PROMPT phase 1, so only the + # top label needs translation. + continue_prompt: "Continue?" + game_over_line1: "Game" + game_over_line2: "Over" menu: titles: diff --git a/data/scenes/game/game.yaml b/data/scenes/game/game.yaml index 6c9f0e6..80a138e 100644 --- a/data/scenes/game/game.yaml +++ b/data/scenes/game/game.yaml @@ -46,3 +46,25 @@ text_panel: color: [36, 182, 170] alpha: 128 height_px: 32 + +# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors +# queden fora de partida durant PLAYING, fa countdown, i si ningú +# pulsa START acaba en GAME OVER + fade-to-black + transició a LOGO. +# Tots els valors són ms (excepte `continue_count_steps`, que és el +# nombre de tics visibles del countdown). Si manca el fitxer o alguna +# clau, GameScene cau als fallbacks compilats (vegeu `GameOverFsm::Config`). +game_over: + # CONTINUE? — animació d'entrada i sortida idèntica al room text. + continue_text_enter_ms: 400 + continue_text_hold_ms: 5000 # 5 s = `continue_count_steps` graons d'1 s + continue_text_exit_ms: 400 + continue_count_steps: 5 + # GAME OVER — apareix sense enter anim (panel ja amunt del CONTINUE?), + # es manté un temps i marxa amb la mateixa exit anim del room text. + game_over_text_hold_ms: 1500 + game_over_text_exit_ms: 400 + game_over_wait_ms: 400 + # Fade final cap a LOGO. `black_hold_ms` és la pausa en negre opac + # abans que el GameScene demani la transició d'escena. + fade_out_ms: 500 + black_hold_ms: 500 diff --git a/source/game/scenes/game_over_fsm.cpp b/source/game/scenes/game_over_fsm.cpp new file mode 100644 index 0000000..f55678b --- /dev/null +++ b/source/game/scenes/game_over_fsm.cpp @@ -0,0 +1,137 @@ +#include "game/scenes/game_over_fsm.hpp" + +#include +#include + +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" +#include "utils/log.hpp" + +namespace { + + // Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el + // fallback. Mateix patró que `RoomFsm::loadConfig`. + auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int { + if (!parent.contains(key)) { return fallback; } + return parent[key].get_value(); + } + +} // namespace + +namespace GameOverFsm { + + auto loadConfig(const std::string& yaml_path) -> Config { + Config cfg{}; + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { + Log::warn("GameOverFsm", "{} absent — uso fallbacks compilats", yaml_path); + return cfg; + } + const auto* begin = reinterpret_cast(bytes.data()); + const auto* end = begin + bytes.size(); + auto yaml = fkyaml::node::deserialize(begin, end); + + if (yaml.contains("game_over")) { + const auto& n = yaml["game_over"]; + cfg.continue_text_enter_ms = readChildInt(n, "continue_text_enter_ms", cfg.continue_text_enter_ms); + cfg.continue_text_hold_ms = readChildInt(n, "continue_text_hold_ms", cfg.continue_text_hold_ms); + cfg.continue_text_exit_ms = readChildInt(n, "continue_text_exit_ms", cfg.continue_text_exit_ms); + cfg.continue_count_steps = readChildInt(n, "continue_count_steps", cfg.continue_count_steps); + cfg.game_over_text_hold_ms = readChildInt(n, "game_over_text_hold_ms", cfg.game_over_text_hold_ms); + cfg.game_over_text_exit_ms = readChildInt(n, "game_over_text_exit_ms", cfg.game_over_text_exit_ms); + cfg.game_over_wait_ms = readChildInt(n, "game_over_wait_ms", cfg.game_over_wait_ms); + cfg.fade_out_ms = readChildInt(n, "fade_out_ms", cfg.fade_out_ms); + cfg.black_hold_ms = readChildInt(n, "black_hold_ms", cfg.black_hold_ms); + } + + Log::info("GameOverFsm", "timings carregats des de {} (continue hold {}ms, fade {}ms)", yaml_path, cfg.continue_text_hold_ms, cfg.fade_out_ms); + return cfg; + } + + auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State { + if (current == State::OFF || current == State::DONE) { return current; } + + t.elapsed_ms += delta_ms; + + if (current == State::CONTINUE_PROMPT) { + const std::array DURS = { + cfg.continue_text_enter_ms, + cfg.continue_text_hold_ms, + cfg.continue_text_exit_ms, + }; + while (t.phase < static_cast(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { + t.elapsed_ms -= DURS[t.phase]; + // Si acabem la fase 1 (hold) sense cancel·lació, saltem + // directament a GAME_OVER — el panel queda amunt i no + // es reprodueix l'exit anim. La fase 2 (exit) només + // s'executa si el GameScene l'ha forçada amb + // `cancelContinuePrompt`. + if (t.phase == 1) { + return State::GAME_OVER; + } + ++t.phase; + } + if (t.phase >= static_cast(DURS.size())) { return State::OFF; } + return current; + } + + if (current == State::GAME_OVER) { + const std::array DURS = { + cfg.game_over_text_hold_ms, + cfg.game_over_text_exit_ms, + cfg.game_over_wait_ms, + }; + while (t.phase < static_cast(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { + t.elapsed_ms -= DURS[t.phase]; + ++t.phase; + } + if (t.phase >= static_cast(DURS.size())) { return State::FADE_OUT; } + return current; + } + + // FADE_OUT + const std::array DURS = { + cfg.fade_out_ms, + cfg.black_hold_ms, + }; + while (t.phase < static_cast(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { + t.elapsed_ms -= DURS[t.phase]; + ++t.phase; + } + if (t.phase >= static_cast(DURS.size())) { return State::DONE; } + return current; + } + + void cancelContinuePrompt(State current, Transition& t) { + if (current != State::CONTINUE_PROMPT) { return; } + if (t.phase >= 2) { return; } // ja en exit anim, no-op + t.phase = 2; + t.elapsed_ms = 0; + } + + auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int { + if (current != State::CONTINUE_PROMPT) { return 0; } + if (t.phase != 1) { return 0; } + const int STEPS = std::max(1, cfg.continue_count_steps); + const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEPS); + const int CONSUMED = t.elapsed_ms / STEP_MS; + return std::clamp(STEPS - CONSUMED, 0, STEPS); + } + + auto stateName(State s) -> const char* { + switch (s) { + case State::OFF: + return "OFF"; + case State::CONTINUE_PROMPT: + return "CONTINUE_PROMPT"; + case State::GAME_OVER: + return "GAME_OVER"; + case State::FADE_OUT: + return "FADE_OUT"; + case State::DONE: + return "DONE"; + } + return "?"; + } + +} // namespace GameOverFsm diff --git a/source/game/scenes/game_over_fsm.hpp b/source/game/scenes/game_over_fsm.hpp new file mode 100644 index 0000000..30f5176 --- /dev/null +++ b/source/game/scenes/game_over_fsm.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include +#include + +// FSM secundària de la GameScene que governa el flux "tots els jugadors +// fora de partida" — CONTINUE? amb countdown → GAME OVER → fade-to-black +// → transició a LOGO. Viu separada de la `RoomFsm` (que gestiona +// intro/outro de cada sala) perquè els seus disparadors són ortogonals +// i la composició entre les dues queda al GameScene. +// +// Lògica pura: no toca SDL ni Audio, només temporitzadors. La part +// visual (text + panel + fade) viu al GameScene reaprofitant la mateixa +// infraestructura del room text. +namespace GameOverFsm { + + // Estats canònics. Phase indices per estat: + // OFF — joc normal; el GameScene transita a + // CONTINUE_PROMPT quan detecta 0 jugadors actius. + // CONTINUE_PROMPT — phases: 0=text_enter, 1=text_hold (countdown + // visible), 2=text_exit (només si l'usuari + // cancel·la pulsant START). Si la fase 1 acaba + // sense cancel·lació → salt a GAME_OVER amb el + // panel encara amunt (sense exit anim). + // GAME_OVER — phases: 0=text_hold (apareix sense enter + // anim, panel ja amunt), 1=text_exit, 2=wait + // (otro tiempo más abans del fade). + // FADE_OUT — phases: 0=fade, 1=black_hold (perquè el + // negre quedi un instant abans de cedir el + // pas a la LogoScene). + // DONE — sentinel: el caller (GameScene) ha de + // demanar la transició cap a LOGO. + enum class State : std::uint8_t { + OFF, + CONTINUE_PROMPT, + GAME_OVER, + FADE_OUT, + DONE, + }; + + // Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a + // {0, 0} quan es canvia d'estat (`GameScene::setGameOverState`). + struct Transition { + int phase{0}; + int elapsed_ms{0}; + }; + + // Timings llegits de `data/scenes/game/game.yaml` (secció + // `game_over:`). Si el fitxer no porta cap d'aquests camps, els + // valors per defecte coincideixen amb els del .yaml versionat. + struct Config { + // CONTINUE? — animació d'entrada/sortida idèntica al room text. + int continue_text_enter_ms{500}; + // Hold = duració total del countdown (es divideix en + // `continue_count_steps` graons d'1 segon visualment). + int continue_text_hold_ms{5000}; + int continue_text_exit_ms{500}; + // Quants "tics" mostra el countdown de CONTINUE?. 5 → "5,4,3,2,1". + int continue_count_steps{5}; + + // GAME OVER — apareix sense enter anim (el panel ja és amunt + // del CONTINUE?) i s'esvaeix amb la mateixa exit anim que el + // room text. `wait_ms` és el "otro tiempo más" abans del fade. + int game_over_text_hold_ms{2000}; + int game_over_text_exit_ms{500}; + int game_over_wait_ms{500}; + + // Fade final cap a LOGO. `black_hold_ms` és el temps amb el + // canvas negre opac abans que el GameScene demani la transició. + int fade_out_ms{500}; + int black_hold_ms{500}; + }; + + // Llegeix la configuració des del YAML. Si el fitxer manca o falta + // alguna clau, els camps no presents conserven els seus fallbacks. + [[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config; + + // Avança la transició consumint `delta_ms` des del temps real. + // Retorna l'estat resultant. Si retorna un estat diferent, el + // caller ha de resetejar la `Transition` a {0, 0} abans del següent + // frame. Funció pura sobre l'argument. + // + // Particularitats: + // - CONTINUE_PROMPT phase 1 (hold) arribar a la fi → GAME_OVER + // (NO reprodueix exit anim; el caller ja té el panel amunt). + // - CONTINUE_PROMPT phase 2 (exit) arribar a la fi → OFF (cas + // "cancel·lació via START": el GameScene ha forçat la fase 2 + // amb `cancelContinuePrompt`). + // - GAME_OVER phase 2 (wait) arribar a la fi → FADE_OUT. + // - FADE_OUT phase 1 (black_hold) arribar a la fi → DONE. + // - DONE i OFF no avancen ni canvien d'estat (sumiders). + [[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State; + + // Salta a la fase 2 (text_exit) del CONTINUE_PROMPT. No fa res si + // l'estat no és CONTINUE_PROMPT o ja som a la fase 2 (idempotent). + // Disparat pel GameScene quan un jugador pulsa START dins del + // countdown — el join real també el fa GameScene; aquesta funció + // només dispara la sortida visual del rètol. + void cancelContinuePrompt(State current, Transition& t); + + // Quants segons "queden" del countdown de CONTINUE?. Retorna un + // enter en el rang [0, continue_count_steps] perquè es renderitzi + // com a segona línia del rètol durant la fase 1 (hold). Fora de + // CONTINUE_PROMPT phase 1, retorna 0 (no s'hauria de pintar). + [[nodiscard]] auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int; + + // Nom textual per a logs i tests. + [[nodiscard]] auto stateName(State s) -> const char*; + +} // namespace GameOverFsm diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index c6c79f7..5265354 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -36,6 +36,7 @@ #include "game/levels/level_manager.hpp" #include "game/map/map.hpp" #include "game/options.hpp" +#include "game/scenes/scene_flow.hpp" #include "game/scenes/start_input.hpp" #include "game/ui/hud.hpp" #include "game/ui/service_menu.hpp" @@ -493,6 +494,10 @@ GameScene::GameScene(Config config) // Timings i corbes de la FSM de sala. Llegits del YAML una sola // vegada; canvis requereixen reiniciar el joc. transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML); + // Timings de la sub-FSM de game over (CONTINUE? + GAME OVER + fade + // final). Comparteix YAML amb la RoomFsm però viu en una secció + // pròpia (`game_over:`). + game_over_config_ = GameOverFsm::loadConfig(Defaults::GameScene::GAME_YAML); // Cinemàtica d'entrada de la primera sala: arrenca la pista // `room_intro.ogg` i arma el callback de fi que farà spawn + salt @@ -700,17 +705,27 @@ void GameScene::onPlayerDisabled(int player_id) { } void GameScene::handleJoinInput() { - // El "join in progress" només es processa durant PLAYING. Durant - // INTRO la cinemàtica de spawn ja té la seva pròpia coreografia - // (vegeu `spawnDuePlayers`); durant OUTRO el cicle de sala ja està - // tancant. En tots dos casos el HUD mostra "ESPERA / PARA JUGAR" - // perquè quede clar que la pulsació encara no té efecte. - if (state_ != RoomFsm::State::PLAYING) { return; } + // El "join in progress" es processa durant PLAYING (regla 1) i, + // com a cas especial, durant CONTINUE_PROMPT — ahí un START reviu + // el jugador i cancel·la el countdown. A partir de GAME_OVER el + // START queda bloquejat (regla 5: ja no es pot pulsar start). + const bool IN_PLAYING = (state_ == RoomFsm::State::PLAYING); + const bool IN_CONTINUE = (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT); + const bool IN_GAME_OVER = (game_over_state_ != GameOverFsm::State::OFF && game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT); + if (IN_GAME_OVER) { return; } + if (!IN_PLAYING && !IN_CONTINUE) { return; } for (std::size_t i = 0; i < players_.size(); ++i) { if (players_active_[i]) { continue; } if (!players_[i]->isDisabled()) { continue; } if (!Input::Detect::pressedStart(static_cast(i))) { continue; } joinPlayer(i); + // Si veníem del countdown de CONTINUE?, disparem la sortida + // visual del rètol. El jugador ja apareix en escena per la + // crida prèvia a `joinPlayer`; el text marxa amb la mateixa + // exit anim que el room text. + if (IN_CONTINUE) { + GameOverFsm::cancelContinuePrompt(game_over_state_, game_over_transition_); + } } } @@ -741,20 +756,29 @@ auto GameScene::activePlayerCount() const -> int { void GameScene::refreshJoinPrompt() { if (!hud_) { return; } - for (std::size_t i = 0; i < players_.size(); ++i) { - Hud::JoinPromptMode mode = Hud::JoinPromptMode::NONE; - if (!players_active_[i]) { - switch (state_) { - case RoomFsm::State::PLAYING: - mode = Hud::JoinPromptMode::CAN_JOIN; - break; - case RoomFsm::State::ROOM_INTRO: - case RoomFsm::State::ROOM_OUTRO: - mode = Hud::JoinPromptMode::WAIT; - break; - } + // El game over substitueix la decisió purament basada en RoomFsm: + // durant CONTINUE_PROMPT els jugadors encara poden entrar (revive), + // a partir de GAME_OVER ja no, així que mostrem WAIT (o NONE) per + // indicar que el START ja no farà res fins a la transició a LOGO. + Hud::JoinPromptMode mode_inactive = Hud::JoinPromptMode::NONE; + if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) { + mode_inactive = Hud::JoinPromptMode::CAN_JOIN; + } else if (game_over_state_ != GameOverFsm::State::OFF) { + mode_inactive = Hud::JoinPromptMode::WAIT; + } else { + switch (state_) { + case RoomFsm::State::PLAYING: + mode_inactive = Hud::JoinPromptMode::CAN_JOIN; + break; + case RoomFsm::State::ROOM_INTRO: + case RoomFsm::State::ROOM_OUTRO: + mode_inactive = Hud::JoinPromptMode::WAIT; + break; } - hud_->setJoinPrompt(static_cast(i) + 1, mode); + } + for (std::size_t i = 0; i < players_.size(); ++i) { + const auto MODE = players_active_[i] ? Hud::JoinPromptMode::NONE : mode_inactive; + hud_->setJoinPrompt(static_cast(i) + 1, MODE); } } @@ -937,12 +961,14 @@ void GameScene::renderRoomText() { const Uint8 OUTLINE = UiConfig::get().roomTextOutline(); const int LINE_HEIGHT = room_text_font_->getLineHeight(); - if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { + const int PHASE = transition_.phase; + const int ELAPSED = transition_.elapsed_ms; + if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) { const int DUR_ENTER = transition_config_.intro_text_enter_ms; const int DUR_EXIT = transition_config_.intro_text_exit_ms; // Panel primer (queda sota el text gràcies a l'ordre de blits). drawRoomTextPanel(PLAY_CENTER_Y, - computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT)); + computeRoomTextPanelHeight(PHASE, ELAPSED, 2, 3, 4, DUR_ENTER, DUR_EXIT)); const auto& mgr = Levels::LevelManager::get(); const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()); @@ -950,12 +976,16 @@ void GameScene::renderRoomText() { // Direccions oposades: piràmide entra per l'esquerra i surt per // la dreta; sala fa el contrari. const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING), + PHASE, + ELAPSED, 2, 4, DUR_ENTER, DUR_EXIT, /*from_right=*/false); const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING), + PHASE, + ELAPSED, 2, 4, DUR_ENTER, @@ -967,21 +997,25 @@ void GameScene::renderRoomText() { const int GAP = transition_config_.intro_text_line_gap_px; drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE); drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); - } else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { + } else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) { const int DUR_ENTER = transition_config_.outro_text_enter_ms; const int DUR_EXIT = transition_config_.outro_text_exit_ms; drawRoomTextPanel(PLAY_CENTER_Y, - computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT)); + computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT)); const std::string LINE_1 = Locale::get().text("game.room_complete_line1"); const std::string LINE_2 = Locale::get().text("game.room_complete_line2"); const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), + PHASE, + ELAPSED, 0, 2, DUR_ENTER, DUR_EXIT, /*from_right=*/false); const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING), + PHASE, + ELAPSED, 0, 2, DUR_ENTER, @@ -993,7 +1027,7 @@ void GameScene::renderRoomText() { } } -auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int { +auto GameScene::computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int { // X animada d'una línia: entra des de fora de pantalla, s'atura al // CENTER_X durant el hold (fora del rang d'aquesta funció — el caller // garanteix que cau a la branca return final) i surt pel costat @@ -1003,16 +1037,16 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int const int OFF_RIGHT = GameCanvas::WIDTH; const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT; const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT; - if (transition_.phase == phase_enter) { + if (phase == phase_enter) { const float T = (dur_enter > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) + ? std::clamp(static_cast(elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T)); } - if (transition_.phase == phase_exit) { + if (phase == phase_exit) { const float T = (dur_exit > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) + ? std::clamp(static_cast(elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T)); @@ -1020,23 +1054,23 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int return GameCanvas::CENTER_X; } -auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int { +auto GameScene::computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int { // Altura animada del panel translúcid sota el rètol. Creix de 0 a // `text_panel_height_px` amb `text_enter_curve` durant l'enter, es // manté plena durant el hold i decreix amb `text_exit_curve` // durant l'exit. Fora d'aquestes tres fases retorna 0. const int MAX = transition_config_.text_panel_height_px; - if (transition_.phase == phase_enter) { + if (phase == phase_enter) { const float T = (dur_enter > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) + ? std::clamp(static_cast(elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; return Easing::lerpInt(0, MAX, CURVE(T)); } - if (transition_.phase == phase_hold) { return MAX; } - if (transition_.phase == phase_exit) { + if (phase == phase_hold) { return MAX; } + if (phase == phase_exit) { const float T = (dur_exit > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) + ? std::clamp(static_cast(elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; return Easing::lerpInt(MAX, 0, CURVE(T)); @@ -1069,6 +1103,158 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin outline_color); } +void GameScene::setGameOverState(GameOverFsm::State new_state) { + if (new_state == game_over_state_) { return; } + Log::info("GAMEOVER", "{} -> {}", GameOverFsm::stateName(game_over_state_), GameOverFsm::stateName(new_state)); + game_over_state_ = new_state; + game_over_transition_ = {}; + if (new_state == GameOverFsm::State::FADE_OUT) { + // Arma el fade en el moment que entrem a la fase de fade-out. + // Mateix patró que `outro_fade_started_` per a OUTRO de sala. + game_over_fade_.begin(game_over_config_.fade_out_ms, + Transitions::FadeToBlack::Direction::FADE_OUT); + game_over_fade_started_ = true; + } +} + +void GameScene::updateGameOverFlow(int delta_ms) { + // Tick del fade visual: és no-op si encara no s'ha armat (begin + // sense duration → tick=no-op gràcies al clamp intern). + game_over_fade_.tick(delta_ms); + + // Detecció d'entrada a CONTINUE_PROMPT: tots els jugadors fora de + // partida durant PLAYING. Només es dispara una vegada — `setGameOverState` + // és idempotent davant del mateix estat. Es vol que la transició + // sigui exclusiva de PLAYING perquè un join mid-INTRO/OUTRO ja no + // és possible (vegeu `handleJoinInput`); permetent CONTINUE? fora + // de PLAYING obriríem races amb la cinemàtica de sala. + if (game_over_state_ == GameOverFsm::State::OFF && state_ == RoomFsm::State::PLAYING && activePlayerCount() == 0) { + setGameOverState(GameOverFsm::State::CONTINUE_PROMPT); + return; + } + if (game_over_state_ == GameOverFsm::State::OFF) { return; } + + const auto NEXT = GameOverFsm::advance(game_over_state_, game_over_transition_, game_over_config_, delta_ms); + if (NEXT != game_over_state_) { + setGameOverState(NEXT); + // En arribar a DONE, demanem la transició a LOGO. La construcció + // de la nova escena la fa el Director llegint `exit_request_`. + if (NEXT == GameOverFsm::State::DONE) { + exit_request_ = ExitRequest{.next = SceneId::LOGO, .params = LogoParams{}}; + Log::info("GAMEOVER", "DONE -> requesting LOGO scene"); + } + } +} + +void GameScene::renderGameOverText() { + if (!room_text_font_) { return; } + if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) { + return; + } + + constexpr int KERNING = 0; + const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2; + const Uint8 FILL = UiConfig::get().roomText(); + const Uint8 OUTLINE = UiConfig::get().roomTextOutline(); + const int LINE_HEIGHT = room_text_font_->getLineHeight(); + const int PHASE = game_over_transition_.phase; + const int ELAPSED = game_over_transition_.elapsed_ms; + + if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) { + const int DUR_ENTER = game_over_config_.continue_text_enter_ms; + const int DUR_EXIT = game_over_config_.continue_text_exit_ms; + // Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel + // segueix la mateixa corba enter/hold/exit que el room text. + drawRoomTextPanel(PLAY_CENTER_Y, + computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT)); + + const std::string LINE_1 = Locale::get().text("game.continue_prompt"); + const int SECONDS = GameOverFsm::countdownSecondsRemaining( + game_over_state_, + game_over_transition_, + game_over_config_); + // Línia 2 només té text durant la fase de hold (countdown). En + // les fases d'enter i exit deixem només el "CONTINUE?" perquè + // l'animació dels números no quedi rara. + const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{}; + + const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), + PHASE, + ELAPSED, + 0, + 2, + DUR_ENTER, + DUR_EXIT, + /*from_right=*/false); + const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING), + PHASE, + ELAPSED, + 0, + 2, + DUR_ENTER, + DUR_EXIT, + /*from_right=*/true); + const int GAP = transition_config_.intro_text_line_gap_px; + drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE); + if (!LINE_2.empty()) { + drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); + } + return; + } + + // GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1 + // (exit). Phase 2 (wait) sense panel ni text — només temps mort + // abans del fade. + const int DUR_HOLD = game_over_config_.game_over_text_hold_ms; + const int DUR_EXIT = game_over_config_.game_over_text_exit_ms; + if (PHASE > 1) { return; } + + // Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem + // computeRoomTextPanelHeight passant un phase_enter "fora de rang" + // (-1) perquè la branca d'enter no s'activi mai. + drawRoomTextPanel(PLAY_CENTER_Y, + computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1, + /*phase_hold=*/0, + /*phase_exit=*/1, + /*dur_enter=*/0, + DUR_EXIT)); + + const std::string LINE_1 = Locale::get().text("game.game_over_line1"); + const std::string LINE_2 = Locale::get().text("game.game_over_line2"); + // Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text + // marxa pels costats com el room outro. Reaprofitem computeRoomTextX + // amb phase_enter=-1 (no enter anim) i phase_exit=1. + const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), + PHASE, + ELAPSED, + /*phase_enter=*/-1, + /*phase_exit=*/1, + /*dur_enter=*/0, + DUR_EXIT, + /*from_right=*/false); + const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING), + PHASE, + ELAPSED, + /*phase_enter=*/-1, + /*phase_exit=*/1, + /*dur_enter=*/0, + DUR_EXIT, + /*from_right=*/true); + const int GAP = transition_config_.outro_text_line_gap_px; + drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE); + drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); + + (void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.). +} + +void GameScene::renderGameOverFade() { + // Sense `begin` previ, `tick` és no-op i `render` pinta alpha=0 + // (vegeu `Transitions::FadeToBlack`). Així el callsite és + // incondicional sense malbaratar pixel ops fora del flux. + if (!game_over_fade_started_) { return; } + game_over_fade_.render(); +} + void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { // Mida del sprite de Dust llegida del seu YAML. Hardcoded per // simplificar — si canvia, només cal tocar dust.yaml + aquesta @@ -1241,6 +1427,12 @@ void GameScene::update(int delta_ms) { // arrenque ja sigui efectiu en el mateix frame. updateRoomTransition(delta_ms); + // Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO). + // També en temps real: el countdown de CONTINUE? no es congela + // amb la pausa de gameplay. La detecció de "tots fora de partida" + // s'arma dins quan estem a PLAYING. + updateGameOverFlow(delta_ms); + #ifdef _DEBUG // Toggle de l'editor: només si no hi ha cap transició de pausa ni // pausa consolidada. L'editor mateix no consumeix la tecla — la @@ -1469,6 +1661,14 @@ void GameScene::render() { // Text de la transició de sala (entrada o sortida): per damunt del // HUD perquè és UI de transició i ha de ser sempre llegible. renderRoomText(); + + // Rètol de game over (CONTINUE? + countdown / GAME OVER) — comparteix + // panel i font amb el room text però viu en una sub-FSM independent. + renderGameOverText(); + + // Fade-to-black final del game over: tapeix tot el canvas (HUD + // inclòs) durant la fase FADE_OUT i el black hold posterior. + renderGameOverFade(); } #ifdef _DEBUG diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index ed13316..38f0dde 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -13,6 +13,7 @@ #include "game/entities/entity_manager.hpp" #include "game/map/paint_layer.hpp" #include "game/map/tilemap.hpp" +#include "game/scenes/game_over_fsm.hpp" #include "game/scenes/room_fsm.hpp" #ifdef _DEBUG @@ -291,15 +292,51 @@ class GameScene : public Scene { void renderRoomFade(); void renderRoomText(); - // Helpers de `renderRoomText`. Tots consulten `transition_` i - // `transition_config_` directament a través de `this` — així les - // signatures es queden netes i la funció pública s'orquestra amb - // poques línies. Tres tasques separades: - // - X animada d'una línia (entra per un costat, surt per l'altre). - // - Altura animada del panel translúcid (creix, manté, decreix). - // - Pintat efectiu del panel i d'una línia de text. - [[nodiscard]] auto computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int; - [[nodiscard]] auto computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int; + // Configuració, estat i transició de la sub-FSM de game over + // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la + // RoomFsm via `GameOverFsm::loadConfig`. + GameOverFsm::Config game_over_config_{}; + GameOverFsm::State game_over_state_{GameOverFsm::State::OFF}; + GameOverFsm::Transition game_over_transition_{}; + + // Fade final cap a LOGO. Independent del `outro_fade_` perquè el + // game over passa per damunt de la RoomFsm i pot disparar-se durant + // PLAYING sense haver d'arrencar un OUTRO de sala. Comença a la + // fase 0 de FADE_OUT i es manté pintant alpha=255 durant el + // `black_hold` final (mateix patró que `outro_fade_`). + Transitions::FadeToBlack game_over_fade_; + bool game_over_fade_started_{false}; + + // Avança la sub-FSM de game over consumint `delta_ms`. Detecta la + // transició de "tots els jugadors fora de partida" durant PLAYING + // (entrada a CONTINUE_PROMPT), arma el fade de FADE_OUT i, en + // arribar a DONE, demana la transició a `LOGO`. + void updateGameOverFlow(int delta_ms); + + // Substitueix `setState` per a la sub-FSM de game over: log + // d'entrada/sortida i reset de la `Transition`. També arma el + // `game_over_fade_` quan entrem a FADE_OUT, en analogia amb + // `outro_fade_`. + void setGameOverState(GameOverFsm::State new_state); + + // Pintat del rètol de game over (CONTINUE? + countdown durant + // CONTINUE_PROMPT; "GAME OVER" durant GAME_OVER). Reaprofita el + // panel translúcid i la font del room text. + void renderGameOverText(); + + // Pintat del fade negre final (FADE_OUT i black hold). Cridat + // després del HUD perquè cobreixi tota la pantalla, inclòs el + // marcador. + void renderGameOverFade(); + + // Helpers d'animació del rètol de transició. Reutilitzats pel + // rètol de sala (`renderRoomText`) i pel de game over + // (`renderGameOverText`). Reben `phase` i `elapsed_ms` per + // paràmetre perquè la mateixa lògica visual serveix les dues + // sub-FSMs sense saber a quina pertanyen — només es lliguen al + // `transition_config_` per a corbes i geometria del panel. + [[nodiscard]] auto computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int; + [[nodiscard]] auto computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int; void drawRoomTextPanel(int center_y, int panel_height) const; void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;