diff --git a/CMakeLists.txt b/CMakeLists.txt index 280d3fc..98da76f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,11 +113,13 @@ set(APP_SOURCES source/game/ui/service_menu.cpp source/game/ui/hud.cpp source/game/scenes/placeholder_input.cpp + source/game/scenes/start_input.cpp source/game/scenes/logo_scene.cpp source/game/scenes/title_scene.cpp 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/audio/music/game_over.ogg b/data/audio/music/game_over.ogg new file mode 100644 index 0000000..e71324c Binary files /dev/null and b/data/audio/music/game_over.ogg differ diff --git a/data/audio/sounds/countdown.wav b/data/audio/sounds/countdown.wav new file mode 100644 index 0000000..abb2798 Binary files /dev/null and b/data/audio/sounds/countdown.wav differ diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index ca17f35..6e4c439 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -38,6 +38,13 @@ hud: treasures: "Tres" pyramid: "Piramide" room: "Sala" + # Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros) + # quan un jugador encara no ha pulsat START o ha esgotat vides. + # Dues línies per fer-los caber al bloc lateral (~106 px). + join_prompt_can_join_l1: "POLSA START" + join_prompt_can_join_l2: "PER JUGAR" + join_prompt_wait_l1: "ESPERA" + join_prompt_wait_l2: "PER JUGAR" # Textos de l'escena TITLE. title: @@ -53,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 32fc53a..da052d4 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -38,6 +38,13 @@ hud: treasures: "Tre" pyramid: "Pyramid" room: "Room" + # Prompts shown in an inactive slot's block (instead of zeros) while + # a player hasn't pressed START yet or has run out of lives. Two + # short lines so they fit in the side block (~106 px wide). + join_prompt_can_join_l1: "PRESS START" + join_prompt_can_join_l2: "TO PLAY" + join_prompt_wait_l1: "PLEASE" + join_prompt_wait_l2: "WAIT" # Texts of the TITLE scene. title: @@ -52,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..5bb349b 100644 --- a/data/scenes/game/game.yaml +++ b/data/scenes/game/game.yaml @@ -46,3 +46,48 @@ 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 + # Hold del countdown: 10 s = (continue_count_start + 1) graons d'1 s. + # La sèrie visible és [continue_count_start, ..., 1, 0]; el "0" es + # manté l'últim segon abans del salt a GAME OVER. + continue_text_hold_ms: 10000 + continue_text_exit_ms: 400 + continue_count_start: 9 + # 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 + # Velocitat (frequency ratio) de la música mentre dura el countdown + # de CONTINUE_PROMPT. 1.0 = sense canvi, 1.5 = un 50% més ràpid (i + # amb el to més agut: efecte arcade clàssic). Es restableix a 1.0 al + # sortir del prompt — tant si el jugador reviu com si entra GAME OVER. + continue_music_speed: 1.05 + # Pista de música que sona durant l'estat GAME_OVER (sense loop). + # La fase de hold del text es sincronitza amb la durada real de la + # pista, així que el rètol "Fi del Joc" es manté visible mentre dura + # la música; després ve l'exit anim, el wait i el fade a LOGO. + game_over_music_track: "game_over.ogg" + # So que sona cada vegada que apareix un nou número del countdown + # (al primer cop que es mostra `continue_count_start`, a cada canvi, + # i quan apareix el 0 — però NO al saltar a GAME OVER). Buit = silenci. + countdown_sound_key: "countdown.wav" + # Llindar a partir del qual qualsevol botó de salt o atac de + # qualsevol jugador avança al següent número del countdown. + # 7 → skip habilitat mentre el número visible és 7..1 (no a 0). + # -1 = mai. Útil per accelerar la decisió un cop la urgència ja + # és evident al jugador. + skip_below_or_equal: 7 diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index 3aca318..7477cd9 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -113,6 +113,13 @@ void Audio::stopMusic() { } } +// NOLINTNEXTLINE(readability-make-member-function-const) +void Audio::setMusicSpeed(float ratio) { + if (music_enabled_) { + engine_->setMusicSpeed(ratio); + } +} + // Reprodueix un so per nom // NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const void Audio::playSound(const std::string& name, Group group) { diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index 26b94c1..1f930a4 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -76,6 +76,11 @@ class Audio { void stopMusic(); // Aturar completament la música void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja) void setOnMusicEnded(std::function callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03) + // Multiplicador de velocitat de la música actual. 1.0 = normal, + // 1.5 = un 50% més ràpid (efecte "chipmunk" — també puja el to). + // Es reseteja a 1.0 implícitament a cada `playMusic`. No-op si no + // hi ha música activa. + void setMusicSpeed(float ratio); // --- Control de sons --- void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja) diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index 9c9a3ac..83ec17a 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -286,6 +286,11 @@ namespace Ja { } } + void Engine::setMusicSpeed(float ratio) { + if (current_music_ == nullptr || current_music_->stream == nullptr) { return; } + SDL_SetAudioStreamFrequencyRatio(current_music_->stream, ratio); + } + void Engine::pauseMusic() { if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; } diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index aba0ba9..88db498 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -171,6 +171,14 @@ namespace Ja { void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1); [[nodiscard]] auto getMusicState() const -> MusicState; auto setMusicVolume(float volume) -> float; + // Multiplicador de velocitat de reproducció de la música actual + // via `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal, 2.0 = + // doble velocitat. Cal saber que també puja el to (efecte + // "chipmunk") — és el comportament arcade clàssic dels comptes + // enrere. Cada `playMusic` crea un stream nou amb ratio 1.0, + // així que un canvi de track reseteja la velocitat + // implícitament. No-op si no hi ha música activa. + void setMusicSpeed(float ratio); // Registra un callback que es disparà quan la música actual acabi de // drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de // stopMusic, així que el callback pot invocar playMusic sense córrer. diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index e0a3c3d..53209a0 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -168,6 +168,9 @@ Director::Director() { Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO); Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING); Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO); + // Pista de game over: pre-load igual que la resta perquè el playMusic + // del moment en què es dispara la sub-FSM no facti disc. + Resource::Cache::get().loadMusic("game_over.ogg", Defaults::Paths::MUSIC_GAME_OVER); // Música de la TITLE: arrenca al constructor de l'escena i es fa // fade-out quan l'escena entra a STARTING. Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE); diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 746eae6..09b4e6f 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -1270,11 +1270,30 @@ void Player::applyDyingMovement(float delta_time) { } } +void Player::resetForJoin() { + lives_remaining_ = death_config.initial_lives; +} + void Player::respawnFromDying() { - // Vides: decreix sempre. Pot anar a negatius — no hi ha game over. + // Vides: decreix una unitat. Si el comptador arriba a 0 el jugador + // surt de partida (DISABLED) i la GameScene/HUD reaccionen via bus + // — això habilita el flux de "join in progress" (l'altre jugador + // pot pulsar START per entrar) i obrirà el camí al CONTINUE? + + // GAME OVER en una fase posterior. --lives_remaining_; death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; + if (lives_remaining_ <= 0) { + disable(); + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_disabled", + .payload = std::monostate{}, + .player_id = id_, + }); + } + return; + } // Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de // dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i // després posa AIRBORNE amb l'altura derivada de la geometria). diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 86fa705..4c1611a 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -127,6 +127,11 @@ class Player : public AnimatedSprite { // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); + // Vides amb què arrenca un jugador (camp `lives:` del YAML del + // jugador). El consumeix el HUD per inicialitzar el comptador del + // marcador i `Player::resetForJoin` per a re-entrades mid-partida. + [[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; } + Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); // Update en una sola crida: manté l'API anterior per als tests i @@ -296,8 +301,11 @@ class Player : public AnimatedSprite { // `death_spinning`, posició congelada, input ignorat. Idempotent — // si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de // momia mentre la roda no la reinicia). Quan l'animació acaba i - // `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a - // GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius). + // `respawn_delay_ms` expira, `respawnFromDying()` decrementa + // `lives_remaining_`: si encara queden vides, fa `spawnFromAir` + // al spawn point; si arriba a 0, fa `disable()` i publica al bus + // `player_disabled` perquè GameScene/HUD reaccionin (slot fora de + // partida, esperant un nou START per re-entrar via "join in progress"). void enterDying(DeathCause cause = DeathCause::GENERIC); // Coordenades del spawn de respawn (cantó superior-esquerra del @@ -309,9 +317,11 @@ class Player : public AnimatedSprite { spawn_y_ = y; } - // Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn; - // no es clampa a 0 — permet anar a negatius perquè avui no hi ha - // game over. Públic per a HUD i tests. + // Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn + // post-mort. En arribar a 0 el jugador passa a DISABLED i publica + // `player_disabled` al bus; pot tornar a partida via "join in + // progress" (GameScene::joinPlayer crida `resetForJoin`). Públic + // per a HUD i tests. [[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; } // Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és @@ -342,6 +352,14 @@ class Player : public AnimatedSprite { // sala usa `disable()` + `spawnFromAir()`. void respawn(float x, float y); + // Restaura el comptador de vides al valor inicial configurat + // (`death_config.initial_lives`). Cridat per la GameScene quan un + // slot inactiu pulsa START per entrar mid-partida (regla 6 de la + // FSM de joc): el jugador entra amb les vides plenes. La resta de + // l'estat (posició, animació, flags) la reseteja `spawnFromAir` + // que GameScene crida tot seguit. + void resetForJoin(); + // 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 @@ -829,8 +847,10 @@ class Player : public AnimatedSprite { // Comptador d'intents (estil Mario). Inicialitzat a `death_config. // initial_lives` al ctor; decreix una vegada per cada respawn - // post-mort (no per respawns de canvi de sala). Pot anar a negatius: - // avui no hi ha game over. + // post-mort (no per respawns de canvi de sala). En arribar a 0, + // `respawnFromDying` posa el jugador a DISABLED i publica + // `player_disabled` al bus. `resetForJoin()` el restaura al valor + // inicial quan el jugador re-entra mid-partida. int lives_remaining_{0}; // Coordenades del spawn de respawn (cantó superior-esquerra del diff --git a/source/game/scenes/game_over_fsm.cpp b/source/game/scenes/game_over_fsm.cpp new file mode 100644 index 0000000..db2e808 --- /dev/null +++ b/source/game/scenes/game_over_fsm.cpp @@ -0,0 +1,167 @@ +#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_start = readChildInt(n, "continue_count_start", cfg.continue_count_start); + 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); + if (n.contains("continue_music_speed")) { + cfg.continue_music_speed = n["continue_music_speed"].get_value(); + } + if (n.contains("game_over_music_track")) { + cfg.game_over_music_track = n["game_over_music_track"].get_value(); + } + if (n.contains("countdown_sound_key")) { + cfg.countdown_sound_key = n["countdown_sound_key"].get_value(); + } + cfg.skip_below_or_equal = readChildInt(n, "skip_below_or_equal", cfg.skip_below_or_equal); + } + + 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; + } + + void skipToNextCountdown(State current, Transition& t, const Config& cfg) { + if (current != State::CONTINUE_PROMPT) { return; } + if (t.phase != 1) { return; } + const int START = std::max(0, cfg.continue_count_start); + const int STEP_COUNT = START + 1; + const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT); + const int CONSUMED = t.elapsed_ms / STEP_MS; + const int CURRENT = std::clamp(START - CONSUMED, 0, START); + // No saltem si ja som al 0: el següent "número" és la transició + // a GAME_OVER, que `advance` ja gestiona quan el hold s'esgota. + if (CURRENT <= 0) { return; } + t.elapsed_ms = (CONSUMED + 1) * STEP_MS; + } + + auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int { + if (current != State::CONTINUE_PROMPT) { return 0; } + if (t.phase != 1) { return 0; } + // La sèrie és [start, start-1, ..., 1, 0]: en total `start+1` + // graons. El primer número visible és `start` i l'últim 0 es + // manté durant el seu graó abans que la fase 1 acabi i la FSM + // salti a GAME_OVER. Si `start == 0` només es mostra "0" + // durant tot el hold. + const int START = std::max(0, cfg.continue_count_start); + const int STEP_COUNT = START + 1; + const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT); + const int CONSUMED = t.elapsed_ms / STEP_MS; + return std::clamp(START - CONSUMED, 0, START); + } + + 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..1239340 --- /dev/null +++ b/source/game/scenes/game_over_fsm.hpp @@ -0,0 +1,150 @@ +#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_start + 1) graons iguals; el cas canònic és + // 10000 ms / 10 graons d'1 s cadascun. + int continue_text_hold_ms{10000}; + int continue_text_exit_ms{500}; + // Primer número visible al countdown. La sèrie és + // [continue_count_start, ..., 1, 0] — sempre acaba en 0 i el + // valor 0 es manté visible durant l'últim graó abans del salt + // a GAME_OVER. 9 → "9, 8, ..., 0" (10 graons). + int continue_count_start{9}; + + // 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}; + + // Multiplicador de velocitat de la música mentre dura el + // CONTINUE_PROMPT. 1.0 = sense canvi, >1 = més ràpid (i amb el + // to més agut: és l'efecte arcade clàssic dels comptes enrere). + // Es restableix a 1.0 al sortir del prompt (revive o GAME_OVER). + float continue_music_speed{1.5F}; + + // Pista de música que sona durant l'estat GAME_OVER (sense loop). + // Si la pista no està carregada al Resource::Cache, s'omet la + // reproducció i la fase manté els timings configurats. La durada + // de la pista determina implícitament `game_over_text_hold_ms` + // (vegeu `GameScene::setGameOverState`). + std::string game_over_music_track{"game_over.ogg"}; + + // So que sona cada vegada que apareix un nou número del + // countdown (al primer cop que es mostra el `start`, cada + // canvi de número, i quan apareix el 0). NO sona en la + // transició posterior cap a GAME_OVER. Buit = sense so. + std::string countdown_sound_key{"countdown.wav"}; + + // Llindar a partir del qual qualsevol botó de salt o atac (de + // qualsevol jugador) salta al següent número del countdown. + // Exemple: 7 → mentre el número visible és 7, 6, 5, ..., 1 + // (no a 0, que ja no té "següent número") un botó d'acció + // avança al següent. Si el valor és més gran que `start` el + // skip queda desactivat efectivament. -1 = mai. + int skip_below_or_equal{7}; + }; + + // 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; + + // Salta al següent número del countdown (decrementa el valor + // visible en una unitat). Només actua dins de CONTINUE_PROMPT + // phase 1 i quan el valor actual és > 0 — al 0 ja no hi ha + // "següent número", la fase ha d'esgotar-se de manera natural i + // el caller es recolza en `advance` per al salt a GAME_OVER. La + // GameScene comprova també el llindar `skip_below_or_equal` + // abans de cridar; aquesta funció, per simplicitat, no en sap. + void skipToNextCountdown(State current, Transition& t, const Config& cfg); + + // 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 d3981b9..a59f496 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -36,6 +36,8 @@ #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" #include "utils/defaults.hpp" @@ -268,7 +270,7 @@ 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}; + players_active_ = {USE_P1, USE_P2}; // Carreguem assets per als DOS jugadors sempre, encara que algun no // arranque actiu: l'slot existeix igualment al vector com a DISABLED @@ -311,6 +313,13 @@ GameScene::GameScene(Config config) entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) { dispatchPlayerKilled(ev.player_id); }); + // `player_disabled` el publica el Player al final del cicle DYING + // quan `lives_remaining_` arriba a 0. La feina viu a + // `onPlayerDisabled` per evitar pujar la complexitat del ctor; el + // HUD ja s'apunta al mateix event per banda. + entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) { + onPlayerDisabled(ev.player_id); + }); // Polvet: precarreguem el surface del Dust una sola vegada perquè // els spawns runtime de l'entitat no facin disc al primer salt. @@ -433,7 +442,7 @@ GameScene::GameScene(Config config) // 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); + const int ACTIVE_COUNT = std::ranges::count(players_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. @@ -447,10 +456,17 @@ GameScene::GameScene(Config config) auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); auto hud_font = std::make_shared(hud_font_surface, UiConfig::get().hudFont().fnt); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); - hud_ = std::make_unique(entity_manager_.getBus(), hud_font); - // Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb - // només P1). Mentre els events del bus no porten `player_id`, els hits - // segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit). + // Vides inicials del HUD = `initial_lives` del YAML del jugador + // (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí). + // Sense això, el marcador es queda fixe a 3 i el YAML perd efecte. + hud_ = std::make_unique(entity_manager_.getBus(), hud_font, Player::getInitialLives()); + // Activem el bloc HUD per als slots que arranquen en partida. Els + // inactius queden mostrant el prompt "POLSA START / PER JUGAR" via + // `refreshJoinPrompt` cada frame. Tots dos s'haurien de poder + // activar (cas BOTH) o un dels dos (cas P1 o P2 sols) — el HUD + // naix amb tots dos slots inactius i és aquesta crida la que dóna + // l'estat real. + if (USE_P1) { hud_->activatePlayer(1); } if (USE_P2) { hud_->activatePlayer(2); } // Sincronització inicial dels comptadors de piràmide/sala del HUD amb // el LevelManager (després de cada `loadRoom` també es refresca; ací @@ -485,6 +501,12 @@ 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); + + preloadGameOverAssets(); // Cinemàtica d'entrada de la primera sala: arrenca la pista // `room_intro.ogg` i arma el callback de fi que farà spawn + salt @@ -494,7 +516,18 @@ GameScene::GameScene(Config config) enterRoomIntro(); } -GameScene::~GameScene() = default; +GameScene::~GameScene() { + // Aturem qualsevol música que estigués sonant al sortir de la + // GameScene. Això cobreix tres camins: (a) transició natural a + // LOGO al final del game over (l'única audio activa pot ser la + // pista `game_over.ogg`, que en aquell moment ja s'ha drenat); + // (b) tancament directe via service menu o ALT+F4; (c) qualsevol + // futura sortida no-natural. La LogoScene té la seva pròpia + // música i no s'ha de mesclar amb la del joc. + if (auto* audio = Audio::get(); audio != nullptr) { + audio->stopMusic(); + } +} auto GameScene::computeSpawn() -> PlayerSpawn { // Centre de la columna del passadís en píxels canvas. La caixa de @@ -542,7 +575,7 @@ void GameScene::loadRoom(const std::string& yaml_path) { for (auto& p : players_) { p->setMapInfo(INFO); } // 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); + const int ACTIVE_COUNT = std::ranges::count(players_active_, true); entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); // Tornem tots els slots a DISABLED. Els actius els reactivarà el @@ -629,10 +662,10 @@ void GameScene::respawnPlayersInstant() { // mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)` // (teleport instantani) en lloc de `spawnFromAir(x, y)`. const auto SPAWN = computeSpawn(); - const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); + const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]); 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; } + if (!players_active_[i]) { continue; } float x = SINGLE_X; if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; } players_[i]->setSpawnPoint(x, SPAWN.y); @@ -647,14 +680,14 @@ void GameScene::respawnPlayersInstant() { void GameScene::triggerPlayerSpawn(std::size_t slot_index) { if (slot_index >= players_.size()) { return; } - if (!players_starts_active_[slot_index]) { return; } + if (!players_active_[slot_index]) { return; } if (players_spawned_in_intro_[slot_index]) { return; } players_spawned_in_intro_[slot_index] = 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]); + const bool BOTH_ACTIVE = (players_active_[0] && players_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. @@ -685,6 +718,103 @@ void GameScene::dispatchPlayerKilled(int player_id) { if (IT != players_.end()) { (*IT)->enterDying(); } } +void GameScene::onPlayerDisabled(int player_id) { + Log::info("PLAYER DISABLED", "player_id={}", player_id); + if (player_id < 0 || player_id >= static_cast(players_active_.size())) { return; } + players_active_[player_id] = false; +} + +void GameScene::preloadGameOverAssets() const { + // Pre-load del so del countdown: el dispara `updateGameOverFlow` + // cada vegada que canvia el número visible de CONTINUE? (incloent + // l'aparició del 0 — però NO la transició a GAME_OVER). Sense + // pre-load, la primera reproducció faria disc amb el countdown ja + // en marxa. + const auto& key = game_over_config_.countdown_sound_key; + if (key.empty()) { return; } + Resource::Cache::get().loadSound( + key, + std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + key); +} + +void GameScene::handleJoinInput() { + // 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_); + } + } +} + +void GameScene::joinPlayer(std::size_t slot_index) { + if (slot_index >= players_.size()) { return; } + // Spawn lateral SEMPRE: cada jugador entra pel seu costat de la + // porta de sortida. Mai el centre (en single-player la cinemàtica + // inicial sí que centra el slot actiu, però en un join mid-partida + // volem el costat per coherència visual amb la regla 2). + const auto SPAWN = computeSpawn(); + const float X = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; + players_[slot_index]->setSpawnPoint(X, SPAWN.y); + players_[slot_index]->resetForJoin(); + players_[slot_index]->spawnFromAir(X, SPAWN.y); + players_active_[slot_index] = true; + if (hud_) { hud_->activatePlayer(static_cast(slot_index) + 1); } + Log::info("ROOM", "join mid-game P{} -> spawn aerial at ({}, {})", slot_index + 1, X, SPAWN.y); +} + +auto GameScene::activePlayerCount() const -> int { + int n = 0; + for (const auto& p : players_) { + if (!p) { continue; } + if (!p->isOutOfPlay() || p->isDying()) { ++n; } + } + return n; +} + +void GameScene::refreshJoinPrompt() { + if (!hud_) { return; } + // 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; + } + } + 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); + } +} + void GameScene::completeIntro() { if (intro_completed_) { return; } intro_completed_ = true; @@ -700,9 +830,9 @@ void GameScene::completeIntro() { void GameScene::spawnDuePlayers() { if (!intro_completed_) { return; } - const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1]; - for (std::size_t i = 0; i < players_starts_active_.size(); ++i) { - if (!players_starts_active_[i]) { continue; } + const bool BOTH_ACTIVE = players_active_[0] && players_active_[1]; + for (std::size_t i = 0; i < players_active_.size(); ++i) { + if (!players_active_[i]) { continue; } if (players_spawned_in_intro_[i]) { continue; } // Single-player: el slot actiu (P1 o P2) sempre fa servir // `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms, @@ -864,12 +994,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()); @@ -877,12 +1009,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, @@ -894,21 +1030,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, @@ -920,7 +1060,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 @@ -930,16 +1070,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)); @@ -947,23 +1087,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)); @@ -996,6 +1136,250 @@ 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; } + const auto PREV = game_over_state_; + Log::info("GAMEOVER", "{} -> {}", GameOverFsm::stateName(PREV), GameOverFsm::stateName(new_state)); + game_over_state_ = new_state; + game_over_transition_ = {}; + + auto* audio = Audio::get(); + + // Sortida de CONTINUE_PROMPT cap a OFF (revive): reinici de la + // música de joc des del principi a velocitat normal. Reaprofitem + // el track gravat a `config_.music_track` (sempre `playing.ogg` + // amb loop infinit). Si la sortida és cap a GAME_OVER, la branca + // de més avall ja talla la música i posa `game_over.ogg` — aquí + // saltem perquè el stop+play seria redundant. + if (PREV == GameOverFsm::State::CONTINUE_PROMPT && new_state == GameOverFsm::State::OFF && audio != nullptr) { + audio->stopMusic(); + audio->playMusic(config_.music_track, -1); + audio->setMusicSpeed(1.0F); + } + + if (new_state == GameOverFsm::State::CONTINUE_PROMPT && audio != nullptr) { + // Acceleració arcade: stop+play perquè la pista arrenqui des + // del principi al ritme accelerat (sense aquest reinici, el + // canvi de velocitat sobre la posició actual queda poc + // perceptible amb ratios subtils com 1.05). El to també puja: + // és l'efecte clàssic dels comptes enrere. + audio->stopMusic(); + audio->playMusic(config_.music_track, -1); + audio->setMusicSpeed(game_over_config_.continue_music_speed); + } + + // Reset del tracker del so del countdown cada vegada que entrem + // a CONTINUE_PROMPT, perquè la primera aparició del valor inicial + // (`continue_count_start`) dispari el "tick". Fora de + // CONTINUE_PROMPT no s'usa. + if (new_state == GameOverFsm::State::CONTINUE_PROMPT) { + last_countdown_value_ = -1; + } + + if (new_state == GameOverFsm::State::GAME_OVER && audio != nullptr) { + // Talla la música actual i arrenca la pista de GAME OVER sense + // loop. La fase de hold es redimensiona a la durada real de la + // pista perquè el text es mantingui visible mentre dura la + // música; l'exit anim, el wait i el fade arrenquen quan la + // música ja s'ha drenat. + audio->stopMusic(); + const auto& track = game_over_config_.game_over_music_track; + if (!track.empty()) { + audio->playMusic(track, 0); + const int DUR_MS = Audio::getMusicDurationMs(track); + if (DUR_MS > 0) { + game_over_config_.game_over_text_hold_ms = DUR_MS; + Log::info("GAMEOVER", "{}: durada {} ms (text_hold sincronitzat)", track, DUR_MS); + } + } + } + + 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"); + } + } + + // Skip i tick del so del countdown viuen en helpers per acotar + // la complexitat cognitiva d'aquesta funció. Skip primer perquè + // el tick del so soni amb el valor nou si la pulsació ha avançat. + handleCountdownSkip(); + emitCountdownTickIfChanged(); +} + +void GameScene::handleCountdownSkip() { + if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; } + if (game_over_transition_.phase != 1) { return; } + const int CURRENT = GameOverFsm::countdownSecondsRemaining( + game_over_state_, + game_over_transition_, + game_over_config_); + if (CURRENT <= 0) { return; } + if (CURRENT > game_over_config_.skip_below_or_equal) { return; } + const bool ANY_ACTION = Input::Detect::pressedActionButton(0) || Input::Detect::pressedActionButton(1); + if (!ANY_ACTION) { return; } + GameOverFsm::skipToNextCountdown( + game_over_state_, + game_over_transition_, + game_over_config_); +} + +void GameScene::emitCountdownTickIfChanged() { + if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; } + if (game_over_transition_.phase != 1) { return; } + const int CURRENT = GameOverFsm::countdownSecondsRemaining( + game_over_state_, + game_over_transition_, + game_over_config_); + if (CURRENT == last_countdown_value_) { return; } + last_countdown_value_ = CURRENT; + const auto& key = game_over_config_.countdown_sound_key; + if (key.empty()) { return; } + if (auto* audio = Audio::get(); audio != nullptr) { + audio->playSound(key, Audio::Group::INTERFACE); + } +} + +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 @@ -1168,6 +1552,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 @@ -1219,6 +1609,20 @@ void GameScene::update(int delta_ms) { } #endif + // Join in progress (regla 1): un slot DISABLED pot entrar a partida + // pulsant START durant PLAYING. Es processa abans del bloc de món + // per que el spawn aeri ja sigui efectiu en aquest mateix frame. + // Gateat per pausa/menú/editor: no es vol que un START accidental + // mentre l'usuari navega per un menú trigueri un join. +#ifdef _DEBUG + const bool EDITOR_OPEN = editor_.active(); +#else + constexpr bool EDITOR_OPEN = false; +#endif + if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) { + handleJoinInput(); + } + GlobalInputs::update(delta_ms); if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } @@ -1279,6 +1683,11 @@ void GameScene::update(int delta_ms) { RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow())); Notifier::get().update(delta_ms); + + // Refresc del prompt al HUD per a slots inactius. Es fa al final per + // recollir els canvis d'aquest frame (joins, deaths, transició de + // RoomFsm) abans del proper render. + refreshJoinPrompt(); } void GameScene::render() { @@ -1377,6 +1786,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 c139d9a..95ec21f 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 @@ -95,17 +96,19 @@ 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_`. + // com en single-player: l'slot inactiu es queda en estat DISABLED i + // pot entrar mid-partida via "join in progress" (`handleJoinInput`). 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}; + // Quins slots estan en partida ARA MATEIX (jugant o DYING). Mutable + // durant la sala: el ctor el sembra des de `config_.starting_players`, + // `joinPlayer` el posa a true quan un slot inactiu pulsa START, i la + // subscripció a `player_disabled` el torna a false quan un slot + // esgota vides. `loadRoom` recalcula `setExpectedPlayerCount` a + // partir d'aquesta vista — un slot DISABLED al final d'una sala no + // entra automàticament a la següent (el jugador ha de re-pulsar + // START si vol re-entrar). + std::array players_active_{false, false}; // Cinemàtica de spawn d'un slot concret. Posa el slot indicat en // AIRBORNE des d'una altura fora de pantalla. Cridat per @@ -130,6 +133,19 @@ class GameScene : public Scene { // Extret a un mètode per acotar la complexitat cognitiva del ctor. void dispatchPlayerKilled(int player_id); + // Handler de l'event `player_disabled` (un slot acaba d'esgotar + // vides). Apaga `players_active_[id]` perquè el `loadRoom` següent + // no compti aquest slot i perquè el "join in progress" del frame + // següent torni a oferir-li `setJoinPrompt(CAN_JOIN)`. Extret a + // mètode per acotar la complexitat cognitiva del subscriber/ctor. + void onPlayerDisabled(int player_id); + + // Pre-load dels recursos d'àudio que s'usen mid-partida i no els + // toca el Director (so del countdown, etc.). Si la clau és buida + // o ja està al cache, és no-op. Extret a mètode per acotar la + // complexitat cognitiva del ctor. + void preloadGameOverAssets() const; + // Itera els slots actius i, si el delay configurat ha expirat des // del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`. // Cridada cada frame durant la fase d'spawn escalonat (entre el @@ -137,6 +153,33 @@ class GameScene : public Scene { // caigut tots o si encara no estem dins la finestra (intro_completed_). void spawnDuePlayers(); + // Atén el "join in progress" (regla 1 de la FSM de joc): si un slot + // està DISABLED i el jugador corresponent pulsa START dins de PLAYING, + // dispara `joinPlayer`. La detecció de tecla/botó la fa + // `Input::detect::pressedStart`. Ignorat fora de PLAYING (durant + // INTRO/OUTRO el slot inactiu mostra "ESPERA / PARA JUGAR" al HUD). + void handleJoinInput(); + + // Materialitza l'entrada d'un slot inactiu: spawn aeri al costat + // corresponent de la porta (`computeSpawn().pX_x`), reset de vides + // al valor inicial via `Player::resetForJoin`, marcatge de + // `players_active_[i]=true` i activació del bloc del HUD. La + // cinemàtica reaprofita `spawnFromAir` (mateixa caiguda visual que + // l'INTRO de sala i el respawn post-mort). + void joinPlayer(std::size_t slot_index); + + // Recompte de jugadors "en partida" (no DISABLED ni EXITED, o bé + // DYING — DYING encara compta com a jugant fins que es resol). Hook + // exposat per a la futura sub-FSM CONTINUE? + GAME OVER, que vigila + // la transició a 0 jugadors actius. No s'usa dins de la Fase 1. + [[nodiscard]] auto activePlayerCount() const -> int; + + // Refresca el prompt mostrat al bloc HUD de cada slot inactiu. Es + // crida cada frame des d'`update()`. La decisió viu ací (que coneix + // `state_` de la RoomFsm); el HUD només pinta. Per a slots actius, + // posa el prompt a NONE per ser defensiu davant de canvis d'estat. + void refreshJoinPrompt(); + // Sentinel que marca el moment "música d'INTRO acabada" dins del // timeline. Ja no ve d'un callback d'àudio: el setja el GameScene // quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`, @@ -255,15 +298,69 @@ 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}; + + // Últim valor del countdown reproduït amb so. -1 com a sentinel + // perquè la primera detecció del valor inicial (`continue_count_start`) + // dispari el "tick". Es reseteja a -1 cada vegada que entrem a + // CONTINUE_PROMPT (vegeu `setGameOverState`). + int last_countdown_value_{-1}; + + // 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(); + + // Comprova si dins de CONTINUE_PROMPT phase 1 el número visible + // està al rang de skip i, si un jugador pulsa salt o atac, fa + // saltar la fase al següent número via `GameOverFsm::skipToNextCountdown`. + // Extret a mètode separat per acotar la complexitat cognitiva + // d'`updateGameOverFlow`. + void handleCountdownSkip(); + + // Detecta canvis de número del countdown i dispara el so + // configurat un cop per cada nou valor. També extret per + // mantenir `updateGameOverFlow` per sota del llindar de tidy. + void emitCountdownTickIfChanged(); + + // 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; diff --git a/source/game/scenes/start_input.cpp b/source/game/scenes/start_input.cpp new file mode 100644 index 0000000..1fc82e3 --- /dev/null +++ b/source/game/scenes/start_input.cpp @@ -0,0 +1,59 @@ +#include "game/scenes/start_input.hpp" + +#include + +#include + +#include "core/input/gamepad_bindings.hpp" +#include "core/input/gamepad_manager.hpp" +#include "core/system/event_buffer.hpp" +#include "game/options.hpp" +#include "utils/defaults.hpp" + +namespace Input::Detect { + + auto pressedStart(int player_id) -> bool { + const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START; + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); + const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0; + const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START); + + return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) { + if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; } + if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast(ev.gbutton.button) == START_BTN) { + return true; + } + return false; + }); + } + + auto pressedActionButton(int player_id) -> bool { + // Teclat: les bindings actives venen d'`Options::controls()` (les + // que el menú de servei pot reescriure), no de `Defaults` cru — + // així respectem el remapping de l'usuari igual que fa el Player. + const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1 + : Options::controls().keyboard_p2; + const SDL_Scancode KB_JUMP = kb.jump; + const SDL_Scancode KB_ATTACK = kb.attack; + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); + const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0; + const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP); + const int ATTACK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK); + + return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) { + if (ev.type == SDL_EVENT_KEY_DOWN && (ev.key.scancode == KB_JUMP || ev.key.scancode == KB_ATTACK)) { + return true; + } + if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID) { + const int BTN = static_cast(ev.gbutton.button); + if (BTN == JUMP_BTN || BTN == ATTACK_BTN) { return true; } + } + return false; + }); + } + +} // namespace Input::Detect diff --git a/source/game/scenes/start_input.hpp b/source/game/scenes/start_input.hpp new file mode 100644 index 0000000..5fce6fb --- /dev/null +++ b/source/game/scenes/start_input.hpp @@ -0,0 +1,28 @@ +#pragma once + +// Helper compartit per detectar pulsacions del botó START dins del frame +// actual per a un jugador concret. Originalment vivia al namespace anònim +// de `title_scene.cpp` perquè era l'únic consumidor; ara la GameScene +// també l'usa per al "join in progress" mid-partida (regla 1 de la FSM +// del jugador), així que extraiem la funció a un namespace propi per +// evitar duplicar la pila d'input (Defaults::Keys + GamepadManager + +// Options + GamepadBindings + EventBuffer) en dos llocs. +namespace Input::Detect { + + // Cert si dins dels events SDL d'aquest frame hi ha una pulsació de + // START per al jugador `player_id` (0 = P1, 1 = P2). Combina events + // de teclat (scancode hardcoded a `Defaults::KeysGame[P2]::START`) i + // de gamepad (botó remapejable via `GamepadBindings`, lligat al pad + // assignat al jugador via `Options::controls()`). + [[nodiscard]] auto pressedStart(int player_id) -> bool; + + // Cert si dins dels events SDL d'aquest frame el jugador + // `player_id` (0 = P1, 1 = P2) ha pulsat el botó de salt o el + // d'atac (qualsevol dels dos). Usat per la GameScene per acceptar + // qualsevol "botó d'acció" com a senyal de skip del countdown de + // CONTINUE? un cop el número visible cau a un llindar configurable. + // Mateix patró que `pressedStart`: combina teclat (Defaults::Keys) + // i gamepad (GamepadBindings). + [[nodiscard]] auto pressedActionButton(int player_id) -> bool; + +} // namespace Input::Detect diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 09fbea5..0bc117f 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -8,8 +8,6 @@ #include #include "core/audio/audio.hpp" -#include "core/input/gamepad_bindings.hpp" -#include "core/input/gamepad_manager.hpp" #include "core/input/global_inputs.hpp" #include "core/locale/locale.hpp" #include "core/rendering/notifier.hpp" @@ -18,11 +16,10 @@ #include "core/rendering/surface.hpp" #include "core/rendering/text.hpp" #include "core/resources/resource_cache.hpp" -#include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "game/options.hpp" #include "game/scenes/scene_flow.hpp" -#include "utils/defaults.hpp" +#include "game/scenes/start_input.hpp" #include "utils/defines.hpp" #include "utils/easing.hpp" #include "utils/log.hpp" @@ -33,27 +30,6 @@ namespace { // docs/audit-core-rendering.md). const char* const SCENE_NAME = "TITLE"; - // Detecta una pulsació de START dins el frame actual per al jugador - // indicat. Combina events de teclat (scancode hardcoded a Defaults:: - // KeysGame[P2]) i events de gamepad (botó remapejable via - // GamepadBindings, lligat al pad assignat al jugador via Options). - auto pressedStart(int player_id) -> bool { - const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START; - SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); - const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0; - const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 - : Options::controls().gamepad_p2; - const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START); - - return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) { - if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; } - if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast(ev.gbutton.button) == START_BTN) { - return true; - } - return false; - }); - } - // Reproducció del so de START. Centralitzat ací per disparar-lo des // dels punts on una pulsació té efecte real (entrada a STARTING o // upgrade a BOTH); l'attract timeout no l'ha de tocar. @@ -139,8 +115,8 @@ void TitleScene::update(int delta_ms) { } } - const bool P1_START = pressedStart(0); - const bool P2_START = pressedStart(1); + const bool P1_START = Input::Detect::pressedStart(0); + const bool P2_START = Input::Detect::pressedStart(1); switch (phase_) { case Phase::IDLE: diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp index 61cb178..455858c 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -69,9 +69,10 @@ namespace { } // namespace -Hud::Hud(EventBus& bus, std::shared_ptr font) +Hud::Hud(EventBus& bus, std::shared_ptr font, int initial_lives) : bus_(bus), - font_(std::move(font)) { + font_(std::move(font)), + initial_lives_(initial_lives) { // Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH. // La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig. const int ENTRY_MS = UiConfig::get().hudEntryMs(); @@ -103,10 +104,17 @@ Hud::Hud(EventBus& bus, std::shared_ptr font) .entry_offset_y = 0, }; - // Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins - // que un segon Player s'uneix mitjançant `activatePlayer(2)`. - players_[0].active = true; + // Naixem amb tots dos slots inactius. El GameScene crida + // `activatePlayer(...)` per als que arrenquen en partida segons + // `starting_players` (un, l'altre, o tots dos). Els no activats + // mostraran el prompt "POLSA START / PER JUGAR" via `setJoinPrompt` + // fins que el jugador faci join. Les vides s'inicialitzen al valor + // del YAML del jugador — sense això, canviar `lives:` no es + // reflectiria al marcador. + players_[0].active = false; + players_[0].lives = initial_lives_; players_[1].active = false; + players_[1].lives = initial_lives_; // Subscripcions al bus de l'EntityManager. De moment només `player_hit` // afecta el HUD visualment: decrementa vides, dispara flash i shake al @@ -136,6 +144,18 @@ Hud::Hud(EventBus& bus, std::shared_ptr font) Log::info("HUD", "level_complete (mode Win pendent)"); })); + // `player_disabled`: el jugador acaba d'esgotar les seves vides al + // final del cicle DYING. Tornem el bloc al render inactiu — el + // prompt textual el fixa GameScene cada frame via `setJoinPrompt`, + // així que ací només cal apagar `active`. Els camps lives/score/ + // treasures es preserven per si en una versió futura volem mostrar + // el resultat final abans de tornar a zero. + subs_.push_back(bus_.subscribe("player_disabled", [this](const Event& ev) { + const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; + players_[IDX].active = false; + Log::info("HUD", "player_disabled p{}", IDX + 1); + })); + Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS); } @@ -164,9 +184,15 @@ void Hud::activatePlayer(int player_one_based) { if (player_one_based < 1 || player_one_based > 2) { return; } auto& p = players_[static_cast(player_one_based - 1)]; p.active = true; - p.lives = DEFAULT_LIVES; + p.lives = initial_lives_; p.score = 0; p.treasures = 0; + p.join_prompt = JoinPromptMode::NONE; +} + +void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) { + if (player_one_based < 1 || player_one_based > 2) { return; } + players_[static_cast(player_one_based - 1)].join_prompt = mode; } auto Hud::computeOffset(const Block& b) -> SDL_FPoint { @@ -234,6 +260,26 @@ void Hud::renderBlock(BlockId id) const { void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const { const auto& p = players_[static_cast(player_index)]; + + // Slot inactiu amb prompt actiu: substituïm el render de zeros pel + // missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons + // el mode fixat per la GameScene cada frame. El render de zeros + // queda com a fallback (mode NONE o slot actiu). + if (!p.active && p.join_prompt != JoinPromptMode::NONE) { + const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN); + const std::string L1 = Locale::get().text( + CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1"); + const std::string L2 = Locale::get().text( + CAN_JOIN ? "hud.join_prompt_can_join_l2" : "hud.join_prompt_wait_l2"); + const int L1_W = font_->length(L1); + const int L2_W = font_->length(L2); + const int X1 = x + ((w - L1_W) / 2); + const int X2 = x + ((w - L2_W) / 2); + font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel()); + font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue()); + return; + } + const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue(); // --- Línia 1: "