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/scenes/game/game.yaml b/data/scenes/game/game.yaml index 1f11ad7..811e875 100644 --- a/data/scenes/game/game.yaml +++ b/data/scenes/game/game.yaml @@ -71,3 +71,17 @@ game_over: # 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" 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/scenes/game_over_fsm.cpp b/source/game/scenes/game_over_fsm.cpp index 8c21f48..9157ac0 100644 --- a/source/game/scenes/game_over_fsm.cpp +++ b/source/game/scenes/game_over_fsm.cpp @@ -42,6 +42,15 @@ namespace GameOverFsm { 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(); + } } Log::info("GameOverFsm", "timings carregats des de {} (continue hold {}ms, fade {}ms)", yaml_path, cfg.continue_text_hold_ms, cfg.fade_out_ms); diff --git a/source/game/scenes/game_over_fsm.hpp b/source/game/scenes/game_over_fsm.hpp index c738bad..ab23d59 100644 --- a/source/game/scenes/game_over_fsm.hpp +++ b/source/game/scenes/game_over_fsm.hpp @@ -73,6 +73,25 @@ namespace GameOverFsm { // 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"}; }; // Llegeix la configuració des del YAML. Si el fitxer manca o falta diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index a8317a5..092ba2c 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -506,6 +506,8 @@ GameScene::GameScene(Config config) // 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 // a PLAYING. Cal cridar `enterRoomIntro` ací (i no només des @@ -514,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 @@ -711,6 +724,19 @@ void GameScene::onPlayerDisabled(int player_id) { 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 @@ -1112,9 +1138,62 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin 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)); + 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. @@ -1151,6 +1230,28 @@ void GameScene::updateGameOverFlow(int delta_ms) { Log::info("GAMEOVER", "DONE -> requesting LOGO scene"); } } + + // Tick del so del countdown: cada vegada que el número visible + // canvia (incloent l'aparició del primer valor i del 0), reproduïm + // el so configurat. La detecció es fa post-`advance` perquè el + // canvi de fase 0 → 1 arribi a aparèixer com a transició -1 → start. + // En la transició cap a GAME_OVER el state ja no és CONTINUE_PROMPT + // i la branca s'omet — així no sona un "tick" extra al saltar. + if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) { + const int CURRENT = GameOverFsm::countdownSecondsRemaining( + game_over_state_, + game_over_transition_, + game_over_config_); + if (game_over_transition_.phase == 1 && CURRENT != last_countdown_value_) { + last_countdown_value_ = CURRENT; + const auto& key = game_over_config_.countdown_sound_key; + if (!key.empty()) { + if (auto* audio = Audio::get(); audio != nullptr) { + audio->playSound(key, Audio::Group::INTERFACE); + } + } + } + } } void GameScene::renderGameOverText() { diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 38f0dde..8cf5341 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -140,6 +140,12 @@ class GameScene : public Scene { // 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 @@ -307,6 +313,12 @@ class GameScene : public Scene { 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 diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index fd80dac..d5c4c80 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -508,6 +508,7 @@ namespace Defaults { constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg"; constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg"; constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg"; + constexpr const char* MUSIC_GAME_OVER = "data/audio/music/game_over.ogg"; // Gràfics de logo / title pre-carregats pel Director. constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif";