feat(player): join in progress + DISABLED en agotar vidas (Fase 1)

Habilita la regla 1 de la nova FSM de joc: durant PLAYING, qualsevol
slot DISABLED pot pulsar START per entrar a partida amb spawn aeri al
seu costat de la porta i vides reseteades. Quan un jugador esgota les
vides, `respawnFromDying` el deixa a DISABLED i publica `player_disabled`
al bus; GameScene i HUD reaccionen, deixant el slot disponible per a un
nou join. Afegeix prompts al HUD ("PULSA START / PER JUGAR" o
"ESPERA / PER JUGAR") segons l'estat de la RoomFsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
JailDesignerandClaude Opus 4.7 committed 2026-05-08 20:04:33 +02:00
1 parent 8921b9fd0c
commit c13b11c501
12 files changed
+325 -56

No files matched your search

+102 -10
View File
@@ -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<float>(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<int>(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<int>(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<int>(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<int>(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() {
+45 -9
View File
@@ -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<std::unique_ptr<Player>> 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<bool, 2> 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<bool, 2> 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`,
+32
View File
@@ -0,0 +1,32 @@
#include "game/scenes/start_input.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#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<int>(ev.gbutton.button) == START_BTN) {
return true;
}
return false;
});
}
} // namespace Input::Detect
+19
View File
@@ -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
+3 -27
View File
@@ -8,8 +8,6 @@
#include <utility>
#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<int>(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: