diff --git a/CMakeLists.txt b/CMakeLists.txt index 280d3fc..57c3759 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,7 @@ 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 diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index ca17f35..1974030 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: diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 32fc53a..c2191c3 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: 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..e0b3f2c 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -296,8 +296,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 +312,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 +347,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 +842,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_scene.cpp b/source/game/scenes/game_scene.cpp index d3981b9..c6c79f7 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -36,6 +36,7 @@ #include "game/levels/level_manager.hpp" #include "game/map/map.hpp" #include "game/options.hpp" +#include "game/scenes/start_input.hpp" #include "game/ui/hud.hpp" #include "game/ui/service_menu.hpp" #include "utils/defaults.hpp" @@ -268,7 +269,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 +312,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 +441,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. @@ -542,7 +550,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 +637,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 +655,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 +693,71 @@ 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::handleJoinInput() { + // El "join in progress" només es processa durant PLAYING. Durant + // INTRO la cinemàtica de spawn ja té la seva pròpia coreografia + // (vegeu `spawnDuePlayers`); durant OUTRO el cicle de sala ja està + // tancant. En tots dos casos el HUD mostra "ESPERA / PARA JUGAR" + // perquè quede clar que la pulsació encara no té efecte. + if (state_ != RoomFsm::State::PLAYING) { return; } + 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); + } +} + +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; } + for (std::size_t i = 0; i < players_.size(); ++i) { + Hud::JoinPromptMode mode = Hud::JoinPromptMode::NONE; + if (!players_active_[i]) { + switch (state_) { + case RoomFsm::State::PLAYING: + mode = Hud::JoinPromptMode::CAN_JOIN; + break; + case RoomFsm::State::ROOM_INTRO: + case RoomFsm::State::ROOM_OUTRO: + mode = Hud::JoinPromptMode::WAIT; + break; + } + } + hud_->setJoinPrompt(static_cast(i) + 1, mode); + } +} + void GameScene::completeIntro() { if (intro_completed_) { return; } intro_completed_ = true; @@ -700,9 +773,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, @@ -1219,6 +1292,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 +1366,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() { diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index c139d9a..ed13316 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -95,17 +95,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 +132,13 @@ 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); + // 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 +146,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`, diff --git a/source/game/scenes/start_input.cpp b/source/game/scenes/start_input.cpp new file mode 100644 index 0000000..3b047b4 --- /dev/null +++ b/source/game/scenes/start_input.cpp @@ -0,0 +1,32 @@ +#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; + }); + } + +} // 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..42fa0eb --- /dev/null +++ b/source/game/scenes/start_input.hpp @@ -0,0 +1,19 @@ +#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; + +} // 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..6070d59 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -136,6 +136,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); } @@ -167,6 +179,12 @@ void Hud::activatePlayer(int player_one_based) { p.lives = DEFAULT_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 +252,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: "