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() {