From 651db50a9307e019cc4419098cea7b28364088a9 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 08:51:56 +0200 Subject: [PATCH 01/18] =?UTF-8?q?feat(game-scene):=20FSM=20m=C3=ADnima=20R?= =?UTF-8?q?OOM=5FINTRO/PLAYING/ROOM=5FOUTRO=20sense=20efectes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 69 +++++++++++++++++++++++++++++-- source/game/scenes/game_scene.hpp | 35 ++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 82c21c8..29b5250 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -263,11 +263,13 @@ GameScene::GameScene(Config config) // Les surfaces de les entitats es carreguen al Resource::Cache aquí. entity_manager_.loadFromLevel(config_.map_yaml); - // Subscripcions bàsiques al bus del nivell: de moment només logs, sense - // tocar ni la vida del jugador ni l'estat de la escena. Serà la base - // sobre la qual cablejar HUD i fi de nivell més endavant. - entity_manager_.getBus().subscribe("level_complete", [](const Event& ev) { + // Subscripcions bàsiques al bus del nivell. `level_complete` també + // dispara la transició a ROOM_OUTRO si encara estem a PLAYING; les + // re-emissions es filtren per estat per no re-entrar mai des d'una + // transició ja en marxa. + entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) { std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n'; + if (state_ == State::PLAYING) { setState(State::ROOM_OUTRO); } }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); @@ -395,10 +397,64 @@ GameScene::GameScene(Config config) auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY); pause_font_ = std::make_shared(pause_surface, UiConfig::get().placeholderFont().fnt); pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning); + + // Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita + // les transicions són instantànies (sense fades ni text) i el primer + // tick d'`updateRoomTransition` ja saltarà a PLAYING. + std::cout << "[ROOM] state=ROOM_INTRO (initial)" << '\n'; } GameScene::~GameScene() = default; +namespace { + // Nom textual d'un State per als logs de transició. L'enum és privat + // a GameScene, així que la funció ha de viure aquí com a lambda + // local del setState — la deixem com a constexpr que rep l'enum + // intern via auto-deducció a partir del seu valor entrant. + constexpr auto stateName(GameScene::State s) -> const char* { + using S = GameScene::State; + switch (s) { + case S::ROOM_INTRO: + return "ROOM_INTRO"; + case S::PLAYING: + return "PLAYING"; + case S::ROOM_OUTRO: + return "ROOM_OUTRO"; + } + return "?"; + } +} // namespace + +void GameScene::setState(State new_state) { + if (new_state == state_) { return; } + std::cout << "[ROOM] " << stateName(state_) << " -> " << stateName(new_state) << '\n'; + state_ = new_state; + // Els acumuladors de la transició de sala (sub-fase, elapsed) viuran + // aquí als commits següents. De moment no en tenim cap. +} + +void GameScene::updateRoomTransition(int /*delta_ms*/) { + // Implementació mínima: no hi ha sub-fases ni fades encara, així que + // ROOM_INTRO i ROOM_OUTRO són efectivament instantanis. El bucle de + // sala queda demostrat (INTRO → PLAYING; al level_complete OUTRO → + // INTRO → PLAYING) sense efectes visuals. Els commits 3 i 4 + // afegiran timings i sub-fases reals. + if (state_ == State::ROOM_INTRO) { + setState(State::PLAYING); + return; + } + if (state_ == State::ROOM_OUTRO) { + // Placeholder fins a l'itinerari de sales: tornem a la mateixa + // sala. No es reinicia res — porta + sarcòfags continuen a + // l'estat OPEN, com era l'acord. + // TODO(itinerari): seleccionar la pròxima sala (incrementar + // `room_index_`, intercanviar `config_.map_yaml`, recrear + // tilemap/entitats) abans de tornar a ROOM_INTRO. + setState(State::ROOM_INTRO); + return; + } +} + 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 @@ -516,6 +572,11 @@ void GameScene::update(int delta_ms) { handlePauseInput(); updatePauseTransition(delta_ms); + // Transició de sala: viu en temps real (no escalada per la pausa) + // perquè és UI. Avança avans del bloc de món perquè quan PLAYING + // arrenque ja sigui efectiu en el mateix frame. + updateRoomTransition(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 diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index f982164..113bee7 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -38,6 +38,18 @@ class GameScene : public Scene { [[nodiscard]] static auto defaultConfig() -> Config; + // Estat de l'escena dins del cicle d'una sala. La màquina és interna a + // GameScene: SceneManager segueix tractant GameScene com una sola + // escena. ROOM_INTRO i ROOM_OUTRO bloquegen input i render dels + // jugadors; l'EntityManager segueix avançant i el HUD/Notifier també. + // L'enum es declara públic perquè els helpers de log (stateName) i + // tests viuen fora de la classe; el camp `state_` segueix privat. + enum class State : std::uint8_t { + ROOM_INTRO, // Entrada de sala: fade in + text d'entrada. + PLAYING, // Joc normal. + ROOM_OUTRO, // Sortida de sala: text de sortida + fade out. + }; + explicit GameScene(Config config = defaultConfig()); ~GameScene() override; @@ -45,6 +57,18 @@ class GameScene : public Scene { void render() override; private: + // Punt centralitzat per canviar d'estat. Imprimeix la transició i + // reinicialitza els acumuladors de la transició de sala. Cap estat + // s'ha de canviar fora d'aquí. + void setState(State new_state); + + // Avança la transició activa (sub-fases, timings). En aquesta fita + // les transicions són instantànies (sense efectes); les sub-fases + // amb fades i text arribaran als commits següents. S'executa cada + // frame amb delta real (no escalat per la pausa) perquè la + // transició viu en temps de UI. + void updateRoomTransition(int delta_ms); + // Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó // START del gamepad primari) per disparar les transicions de pausa. // No alterna un flag binari: inicia (o inverteix mid-flight) un @@ -131,6 +155,17 @@ class GameScene : public Scene { UiConfig::CurveFn curve{nullptr}; // Corba activa. } pause_; + // Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor + // perquè cada vegada que entres al joc passes per la presentació de + // la sala. La transició a PLAYING l'orquestra `updateRoomTransition`. + State state_{State::ROOM_INTRO}; + + // Índex de sala que es passa com a paràmetre {n} al text d'intro. + // Avui és sempre 1 (no hi ha itinerari de sales encara). Quan + // arribi l'itinerari, és el camp que es modificarà al transicionar + // de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima. + int room_index_{1}; + #ifdef _DEBUG // Editor modal del tilemap. Mútuament excloent amb la pausa: si la // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i From 368f9d2d6a2c2382a23e1c6bb6cf470ef4bd91fa Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 08:54:45 +0200 Subject: [PATCH 02/18] =?UTF-8?q?feat(scenes):=20data/scenes/game.yaml=20a?= =?UTF-8?q?mb=20timings=20i=20corbes=20de=20transici=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/scenes/game.yaml | 24 ++++++++++++ source/game/scenes/game_scene.cpp | 61 +++++++++++++++++++++++++++++++ source/game/scenes/game_scene.hpp | 25 +++++++++++++ source/utils/defaults.hpp | 7 ++++ 4 files changed, 117 insertions(+) create mode 100644 data/scenes/game.yaml diff --git a/data/scenes/game.yaml b/data/scenes/game.yaml new file mode 100644 index 0000000..93e04ee --- /dev/null +++ b/data/scenes/game.yaml @@ -0,0 +1,24 @@ +# Configuració de la GameScene: timings i corbes de la FSM de sala +# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria +# i font del text es reutilitzen del tema UI (data/ui.yaml > pause + +# notification). Si manca el fitxer o alguna clau, GameScene cau als +# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp. + +room_intro: + fade_in_ms: 500 # negre opac → món visible + text_delay_ms: 500 # espera amb el món net abans de sortir el text + text_enter_ms: 500 # text esquerra → centre + text_hold_ms: 1000 # text quiet al centre + text_exit_ms: 500 # text centre → fora per l'esquerra + +room_outro: + text_enter_ms: 500 + text_hold_ms: 1000 + text_exit_ms: 500 + fade_out_ms: 500 # món visible → negre opac + +text: + # Noms d'easing tal com els resol UiConfig::curveByName (vegeu + # `data/ui.yaml`). El text entra suau i surt accelerant. + enter_curve: outCubic + exit_curve: inCubic diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 29b5250..be6254e 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -74,6 +74,63 @@ namespace { return yaml[key].get_value(); } + // Llegeix un sub-camp sencer del bloc `parent` (o el fallback) sense + // imprimir res. Pensat per a la lectura puntual del data/scenes/game.yaml. + auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int { + if (!parent.contains(key)) { return fallback; } + return parent[key].get_value(); + } + + // Carrega `data/scenes/game.yaml` i emplena un SceneTransitionConfig. + // Si el fitxer falta o algun camp no hi és, els valors de fallback + // que ja porta l'estructura es conserven. Les dues corbes es resolen + // per nom amb `UiConfig::curveByName` (mateix mecanisme que la pausa); + // si el nom és desconegut, cau a `Easing::linear` amb warning al log. + auto readSceneConfig(const std::string& yaml_path) -> GameScene::SceneTransitionConfig { + GameScene::SceneTransitionConfig cfg{}; + cfg.text_enter_curve = UiConfig::curveByName("outCubic"); + cfg.text_exit_curve = UiConfig::curveByName("inCubic"); + + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { + std::cout << "GameScene: " << yaml_path << " absent — uso fallbacks compilats\n"; + 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("room_intro")) { + const auto& n = yaml["room_intro"]; + cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms); + cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms); + cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms); + cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms); + cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms); + } + if (yaml.contains("room_outro")) { + const auto& n = yaml["room_outro"]; + cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms); + cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms); + cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms); + cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms); + } + if (yaml.contains("text")) { + const auto& n = yaml["text"]; + if (n.contains("enter_curve")) { + cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value()); + } + if (n.contains("exit_curve")) { + cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value()); + } + } + + std::cout << "GameScene: timings carregats des de " << yaml_path + << " (intro fade " << cfg.intro_fade_in_ms + << "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n"; + return cfg; + } + // Comprova si `a` i `b` se solapen amb una tolerància positiva al // contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja // ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa @@ -398,6 +455,10 @@ GameScene::GameScene(Config config) pause_font_ = std::make_shared(pause_surface, UiConfig::get().placeholderFont().fnt); pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning); + // Timings i corbes de la FSM de sala. Llegits del YAML una sola + // vegada; canvis requereixen reiniciar el joc. + transition_config_ = readSceneConfig(Defaults::GameScene::CONFIG_YAML); + // Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita // les transicions són instantànies (sense fades ni text) i el primer // tick d'`updateRoomTransition` ja saltarà a PLAYING. diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 113bee7..3dd06e6 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -50,6 +50,27 @@ class GameScene : public Scene { ROOM_OUTRO, // Sortida de sala: text de sortida + fade out. }; + // Configuració de la FSM de sala llegida de `data/scenes/game.yaml`. + // Si el YAML manca, els camps queden amb els fallbacks compilats. Es + // carrega un sol cop al ctor; canvis al fitxer requereixen reiniciar. + // Pública pel mateix motiu que State: el reader viu en un anonymous + // namespace fora de la classe per simetria amb els altres readers. + struct SceneTransitionConfig { + int intro_fade_in_ms{500}; + int intro_text_delay_ms{500}; + int intro_text_enter_ms{500}; + int intro_text_hold_ms{1000}; + int intro_text_exit_ms{500}; + + int outro_text_enter_ms{500}; + int outro_text_hold_ms{1000}; + int outro_text_exit_ms{500}; + int outro_fade_out_ms{500}; + + UiConfig::CurveFn text_enter_curve{nullptr}; + UiConfig::CurveFn text_exit_curve{nullptr}; + }; + explicit GameScene(Config config = defaultConfig()); ~GameScene() override; @@ -155,6 +176,10 @@ class GameScene : public Scene { UiConfig::CurveFn curve{nullptr}; // Corba activa. } pause_; + // Configuració de la FSM de sala (intro/outro). Carregada al ctor des + // de `data/scenes/game.yaml`; valors per defecte si manca el YAML. + SceneTransitionConfig transition_config_{}; + // Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor // perquè cada vegada que entres al joc passes per la presentació de // la sala. La transició a PLAYING l'orquestra `updateRoomTransition`. diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index bedd74f..0b4149e 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -220,6 +220,13 @@ namespace Defaults { } // namespace Editor #endif + // Configuració de l'escena de joc (timings i corbes de la FSM de sala). + // Independent de la piràmide concreta — quan un dia hi haja diferents + // ritmes per piràmide, aquest YAML pot adoptar variants per nivell. + namespace GameScene { + constexpr const char* CONFIG_YAML = "data/scenes/game.yaml"; + } // namespace GameScene + // Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no // en contingui cap literal; afegir una piràmide nova serà un altre // conjunt de constants + un Config distint passat al ctor. From 67b06c0a109f6a5a2ef3faf6af8227ee4eb4ab30 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:00:15 +0200 Subject: [PATCH 03/18] =?UTF-8?q?feat(game-scene):=20ROOM=5FINTRO=20?= =?UTF-8?q?=E2=80=94=20fade=20in=20del=20m=C3=B3n=20i=20text=20d'entrada?= =?UTF-8?q?=20de=20sala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 194 ++++++++++++++++++++++++------ source/game/scenes/game_scene.hpp | 23 ++++ 2 files changed, 179 insertions(+), 38 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index be6254e..9528bba 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -3,11 +3,14 @@ #include #include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -490,18 +493,34 @@ void GameScene::setState(State new_state) { if (new_state == state_) { return; } std::cout << "[ROOM] " << stateName(state_) << " -> " << stateName(new_state) << '\n'; state_ = new_state; - // Els acumuladors de la transició de sala (sub-fase, elapsed) viuran - // aquí als commits següents. De moment no en tenim cap. + transition_ = {}; // sub-fase i elapsed s'inicialitzen a zero a cada estat. } -void GameScene::updateRoomTransition(int /*delta_ms*/) { - // Implementació mínima: no hi ha sub-fases ni fades encara, així que - // ROOM_INTRO i ROOM_OUTRO són efectivament instantanis. El bucle de - // sala queda demostrat (INTRO → PLAYING; al level_complete OUTRO → - // INTRO → PLAYING) sense efectes visuals. Els commits 3 i 4 - // afegiran timings i sub-fases reals. +void GameScene::updateRoomTransition(int delta_ms) { + if (state_ == State::PLAYING) { return; } + + transition_.elapsed_ms += delta_ms; + + // Avança totes les sub-fases que càpiguen al delta acumulat (típicament + // una, però si el delta és gran o la sub-fase és molt curta podria + // saltar més d'una en un sol frame). En sortir del bucle, o bé estem + // dins d'una sub-fase amb temps restant, o bé hem consumit totes les + // sub-fases de l'estat i toca canviar d'estat. if (state_ == State::ROOM_INTRO) { - setState(State::PLAYING); + const std::array DURS = { + transition_config_.intro_fade_in_ms, + transition_config_.intro_text_delay_ms, + transition_config_.intro_text_enter_ms, + transition_config_.intro_text_hold_ms, + transition_config_.intro_text_exit_ms, + }; + while (transition_.phase < static_cast(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) { + transition_.elapsed_ms -= DURS[transition_.phase]; + ++transition_.phase; + } + if (transition_.phase >= static_cast(DURS.size())) { + setState(State::PLAYING); + } return; } if (state_ == State::ROOM_OUTRO) { @@ -510,12 +529,83 @@ void GameScene::updateRoomTransition(int /*delta_ms*/) { // l'estat OPEN, com era l'acord. // TODO(itinerari): seleccionar la pròxima sala (incrementar // `room_index_`, intercanviar `config_.map_yaml`, recrear - // tilemap/entitats) abans de tornar a ROOM_INTRO. + // tilemap/entitats) abans de tornar a ROOM_INTRO. La + // implementació de les sub-fases d'OUTRO arribarà al commit 4. setState(State::ROOM_INTRO); return; } } +auto GameScene::formatRoomTitle(int n) -> std::string { + // Substitució mínima `{n}` → enter. Locale::text retorna la pròpia + // clau si no troba traducció, així que quan els fitxers de locale + // encara no porten la clau el text serà visible com a "game.room_intro" + // i no com una cadena buida (útil per a debug en pre-locales). + auto tpl = Locale::get().text("game.room_intro"); + constexpr std::string_view PLACEHOLDER = "{n}"; + auto pos = tpl.find(PLACEHOLDER); + if (pos != std::string::npos) { + tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n)); + } + return tpl; +} + +void GameScene::renderRoomFade() { + // Fade negre opac → transparent durant ROOM_INTRO/fase 0. Pintat + // sobre el món un cop els actors ja s'han dibuixat. Al commit 4 + // s'afegirà el fade out simètric per a ROOM_OUTRO/fase final. + if (state_ != State::ROOM_INTRO || transition_.phase != 0) { return; } + const int DUR = transition_config_.intro_fade_in_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + : 1.0F; + const auto ALPHA = static_cast(255.0F * (1.0F - T)); + if (ALPHA == 0) { return; } + Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA); +} + +void GameScene::renderRoomText() { + // Text d'entrada de sala. Visible només durant les sub-fases 2-4 + // de ROOM_INTRO (enter, hold, exit). Reusa la font cachejada per + // a la pausa: mateix tipus de lletra i mateix índex de color. + if (state_ != State::ROOM_INTRO || transition_.phase < 2 || transition_.phase > 4) { return; } + if (!pause_font_) { return; } + + const std::string TEXT = formatRoomTitle(room_index_); + constexpr int KERNING = 0; + const int W = pause_font_->length(TEXT, KERNING); + const int OFF_LEFT = -W; + int x = GameCanvas::CENTER_X; + + if (transition_.phase == 2) { + const int DUR = transition_config_.intro_text_enter_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + : 1.0F; + const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; + x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T)); + } else if (transition_.phase == 4) { + const int DUR = transition_config_.intro_text_exit_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + : 1.0F; + const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; + x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_LEFT, CURVE(T)); + } + + constexpr Uint8 OUTLINE_WHITE = 255; + constexpr Uint8 STROKE_DIST = 1; + const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2); + pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, + x, + Y, + TEXT, + KERNING, + UiConfig::get().notificationText(), + STROKE_DIST, + OUTLINE_WHITE); +} + 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 @@ -661,22 +751,29 @@ void GameScene::update(int delta_ms) { constexpr bool EDITOR_FROZEN = false; #endif const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); + const bool GAMEPLAY_ACTIVE = (state_ == State::PLAYING); if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; - // Pipeline de moviment en tres passes per poder intercalar la - // resolució de contactes jugador↔jugador entre input i posició: - // 1) tick: cada jugador calcula la velocitat intencionada - // (teclat + mando + facing). - // 2) resolve: si els dos es trobarien, anul·lem velocitats - // (head-on) o escalem a push_factor (empenta). - // 3) applyMovement: en l'ordre "pushed primer" perquè quan el - // pusher faça `tryMove`, el pushed ja estiga fora de la - // seua caixa. La callback també tracta l'altre jugador com - // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. - for (auto& player : players_) { player->tick(DT); } - resolvePlayerVsPlayer(players_view_, DT, push_factor_); - for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } + // Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni + // poden col·lisionar amb entitats. Les entitats sí, perquè el + // disseny demana que el món continui animant-se durant les + // transicions de sala. + if (GAMEPLAY_ACTIVE) { + // Pipeline de moviment en tres passes per poder intercalar la + // resolució de contactes jugador↔jugador entre input i posició: + // 1) tick: cada jugador calcula la velocitat intencionada + // (teclat + mando + facing). + // 2) resolve: si els dos es trobarien, anul·lem velocitats + // (head-on) o escalem a push_factor (empenta). + // 3) applyMovement: en l'ordre "pushed primer" perquè quan el + // pusher faça `tryMove`, el pushed ja estiga fora de la + // seua caixa. La callback també tracta l'altre jugador com + // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. + for (auto& player : players_) { player->tick(DT); } + resolvePlayerVsPlayer(players_view_, DT, push_factor_); + for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } + } // El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha // emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat @@ -687,7 +784,9 @@ void GameScene::update(int delta_ms) { // Detecció de contacte player↔entitat després que totes les entitats // hagin mogut aquest frame. Reutilitzem la vista plana que ja // manté el GameScene per al resolver player↔player. - entity_manager_.checkPlayerCollisions(players_view_); + if (GAMEPLAY_ACTIVE) { + entity_manager_.checkPlayerCollisions(players_view_); + } } #ifdef _DEBUG @@ -726,12 +825,19 @@ void GameScene::render() { // explícita: l'editor edita el tilemap, no la capa de pintat). if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS) { - // Passada de sombres dels jugadors saltant. Va per davant del - // pintat (al damunt) però per darrere de TOTS els actors — - // entitats i jugadors, inclòs el saltador mateix— així la sombra - // queda al sòl sense tapar res. `renderShadow` és no-op si el - // jugador està a terra, així que no cal filtrar al callsite. - for (const auto& p : players_) { p->renderShadow(); } + // Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen. + // Les entitats sí que es renderitzen perquè el disseny demana que + // el món continui animant-se durant el fade i el text. + const bool RENDER_PLAYERS = (state_ == State::PLAYING); + + if (RENDER_PLAYERS) { + // Passada de sombres dels jugadors saltant. Va per davant del + // pintat (al damunt) però per darrere de TOTS els actors — + // entitats i jugadors, inclòs el saltador mateix— així la sombra + // queda al sòl sense tapar res. `renderShadow` és no-op si el + // jugador està a terra, així que no cal filtrar al callsite. + for (const auto& p : players_) { p->renderShadow(); } + } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // els actors amb els peus més amunt queden dibuixats primer (al fons), // els més avall al davant. Així un sarc situat per darrere d'un player @@ -742,14 +848,16 @@ void GameScene::render() { Player* player; // nullable; si és nullptr, s'usa `entity`. }; std::vector order; - order.reserve(entity_manager_.entityCount() + players_.size()); + order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0)); const auto ENTITIES = entity_manager_.getEntities(); std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; }); - std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { - return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; - }); + if (RENDER_PLAYERS) { + std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { + return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; + }); + } std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { return a.feet_y < b.feet_y; }); @@ -760,11 +868,21 @@ void GameScene::render() { r.player->render(); } } - // HUD: a sobre dels actors perquè la franja superior tape qualsevol - // sprite que (durant l'animació d'entrada) puga sortir més amunt - // del playfield. S'amaga en mode editor (l'editor edita el tilemap; - // el HUD distrauria) però segueix actualitzant-se per fora. + + // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les + // notificacions i el service menu queden per damunt sense afectar-se. + // Inactiu si l'estat és PLAYING. + renderRoomFade(); + + // HUD: a sobre dels actors (i del fade) perquè la franja superior + // tape qualsevol sprite que (durant l'animació d'entrada) puga + // sortir més amunt del playfield. S'amaga en mode editor però + // segueix actualitzant-se per fora. if (hud_) { hud_->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(); } #ifdef _DEBUG diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 3dd06e6..50b73a4 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -185,12 +185,35 @@ class GameScene : public Scene { // la sala. La transició a PLAYING l'orquestra `updateRoomTransition`. State state_{State::ROOM_INTRO}; + // Sub-fase i temps acumulat dins de l'estat actual. `setState` el + // reseteja a {0, 0}. La interpretació depèn de l'estat: + // ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit + // ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out + // PLAYING: no s'usa (updateRoomTransition no avança res). + struct RoomTransition { + int phase{0}; + int elapsed_ms{0}; + } transition_{}; + // Índex de sala que es passa com a paràmetre {n} al text d'intro. // Avui és sempre 1 (no hi ha itinerari de sales encara). Quan // arribi l'itinerari, és el camp que es modificarà al transicionar // de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima. int room_index_{1}; + // Substitueix `{n}` de `Locale::get().text("game.room_intro")` per + // l'enter passat. Pensat com a primer pas mínim cap a un format + // genèric: si apareix més text parametritzat, val la pena ascendir + // a un `Locale::format` propi. Estàtica perquè no depèn d'estat de + // l'escena (només consulta el Locale singleton). + [[nodiscard]] static auto formatRoomTitle(int n) -> std::string; + + // Píxels del fade negre i del text de la transició de sala. + // Cridats des de `render()` entre el bloc d'actors i el HUD — + // així marcador, notificacions i service menu queden per damunt. + void renderRoomFade(); + void renderRoomText(); + #ifdef _DEBUG // Editor modal del tilemap. Mútuament excloent amb la pausa: si la // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i From 4a9a037cd8a9ee44544b5a4a3cd5ca05930a400c Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:02:30 +0200 Subject: [PATCH 04/18] =?UTF-8?q?feat(game-scene):=20ROOM=5FOUTRO=20?= =?UTF-8?q?=E2=80=94=20text=20de=20sortida=20i=20fade=20out=20del=20m?= =?UTF-8?q?=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 107 +++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 9528bba..bc7e04e 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -524,14 +524,25 @@ void GameScene::updateRoomTransition(int delta_ms) { return; } if (state_ == State::ROOM_OUTRO) { - // Placeholder fins a l'itinerari de sales: tornem a la mateixa - // sala. No es reinicia res — porta + sarcòfags continuen a - // l'estat OPEN, com era l'acord. - // TODO(itinerari): seleccionar la pròxima sala (incrementar - // `room_index_`, intercanviar `config_.map_yaml`, recrear - // tilemap/entitats) abans de tornar a ROOM_INTRO. La - // implementació de les sub-fases d'OUTRO arribarà al commit 4. - setState(State::ROOM_INTRO); + const std::array DURS = { + transition_config_.outro_text_enter_ms, + transition_config_.outro_text_hold_ms, + transition_config_.outro_text_exit_ms, + transition_config_.outro_fade_out_ms, + }; + while (transition_.phase < static_cast(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) { + transition_.elapsed_ms -= DURS[transition_.phase]; + ++transition_.phase; + } + if (transition_.phase >= static_cast(DURS.size())) { + // Placeholder fins a l'itinerari de sales: tornem a la mateixa + // sala. No es reinicia res — porta + sarcòfags continuen a + // l'estat OPEN, com era l'acord. + // TODO(itinerari): seleccionar la pròxima sala (incrementar + // `room_index_`, intercanviar `config_.map_yaml`, recrear + // tilemap/entitats) abans de tornar a ROOM_INTRO. + setState(State::ROOM_INTRO); + } return; } } @@ -551,43 +562,77 @@ auto GameScene::formatRoomTitle(int n) -> std::string { } void GameScene::renderRoomFade() { - // Fade negre opac → transparent durant ROOM_INTRO/fase 0. Pintat - // sobre el món un cop els actors ja s'han dibuixat. Al commit 4 - // s'afegirà el fade out simètric per a ROOM_OUTRO/fase final. - if (state_ != State::ROOM_INTRO || transition_.phase != 0) { return; } - const int DUR = transition_config_.intro_fade_in_ms; - const float T = (DUR > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + // Fade negre sobre el món, pintat un cop els actors ja estan al + // canvas i abans del HUD. Dos casos: + // ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent) + // ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga) + int dur = 0; + float t = 0.0F; + bool fade_in = false; + if (state_ == State::ROOM_INTRO && transition_.phase == 0) { + dur = transition_config_.intro_fade_in_ms; + fade_in = true; + } else if (state_ == State::ROOM_OUTRO && transition_.phase == 3) { + dur = transition_config_.outro_fade_out_ms; + fade_in = false; + } else { + return; + } + t = (dur > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur), 0.0F, 1.0F) : 1.0F; - const auto ALPHA = static_cast(255.0F * (1.0F - T)); + const float ALPHA_F = fade_in ? (1.0F - t) : t; + const auto ALPHA = static_cast(255.0F * ALPHA_F); if (ALPHA == 0) { return; } Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA); } void GameScene::renderRoomText() { - // Text d'entrada de sala. Visible només durant les sub-fases 2-4 - // de ROOM_INTRO (enter, hold, exit). Reusa la font cachejada per - // a la pausa: mateix tipus de lletra i mateix índex de color. - if (state_ != State::ROOM_INTRO || transition_.phase < 2 || transition_.phase > 4) { return; } + // Text de la transició de sala. Reusa la font cachejada per a la + // pausa (mateix tipus de lletra, mateix índex de color), i fa el + // mateix patró esquerra → centre → esquerra que el text de pausa + // però amb les durades pròpies de la transició. + // + // Mapatge per estat (només els estats amb text actiu hi entren): + // ROOM_INTRO: phases 2..4 → enter / hold / exit; text = sala N + // ROOM_OUTRO: phases 0..2 → enter / hold / exit; text = "fase completada" if (!pause_font_) { return; } - const std::string TEXT = formatRoomTitle(room_index_); + std::string text; + int phase_enter = 0; + int phase_exit = 0; + int dur_enter = 0; + int dur_exit = 0; + if (state_ == State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { + text = formatRoomTitle(room_index_); + phase_enter = 2; + phase_exit = 4; + dur_enter = transition_config_.intro_text_enter_ms; + dur_exit = transition_config_.intro_text_exit_ms; + } else if (state_ == State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { + text = Locale::get().text("game.room_complete"); + phase_enter = 0; + phase_exit = 2; + dur_enter = transition_config_.outro_text_enter_ms; + dur_exit = transition_config_.outro_text_exit_ms; + } else { + return; + } + constexpr int KERNING = 0; - const int W = pause_font_->length(TEXT, KERNING); + const int W = pause_font_->length(text, KERNING); const int OFF_LEFT = -W; int x = GameCanvas::CENTER_X; - if (transition_.phase == 2) { - const int DUR = transition_config_.intro_text_enter_ms; - const float T = (DUR > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + if (transition_.phase == phase_enter) { + const float T = (dur_enter > 0) + ? std::clamp(static_cast(transition_.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; x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T)); - } else if (transition_.phase == 4) { - const int DUR = transition_config_.intro_text_exit_ms; - const float T = (DUR > 0) - ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + } else if (transition_.phase == phase_exit) { + const float T = (dur_exit > 0) + ? std::clamp(static_cast(transition_.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; x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_LEFT, CURVE(T)); @@ -599,7 +644,7 @@ void GameScene::renderRoomText() { pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, x, Y, - TEXT, + text, KERNING, UiConfig::get().notificationText(), STROKE_DIST, From 9a2c67715324bcb611922c46eaf30180be1d8260 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:03:37 +0200 Subject: [PATCH 05/18] feat(locale): claus game.room_intro / game.room_complete (ca + en) --- data/locale/ca.yaml | 7 +++++++ data/locale/en.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index f15bb53..b14899a 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -36,6 +36,13 @@ hud: pyramid: "Piramide" room: "Sala" +# Textos de la transició d'entrada/sortida de sala (FSM de GameScene). +# `room_intro` accepta el placeholder `{n}` que es substitueix amb +# l'índex de sala actual; veure `GameScene::formatRoomTitle`. +game: + room_intro: "Sala {n}" + room_complete: "Fase completada" + menu: titles: root: "Menú de servei" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index a5fe094..72b3020 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -36,6 +36,13 @@ hud: pyramid: "Pyramid" room: "Room" +# Room transition texts (GameScene FSM intro/outro). The `room_intro` +# string accepts a `{n}` placeholder that is replaced with the current +# room index; see `GameScene::formatRoomTitle`. +game: + room_intro: "Room {n}" + room_complete: "Stage complete" + menu: titles: root: "Service menu" From 63bb41425a467c2907621673812931524d8325f1 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:10:54 +0200 Subject: [PATCH 06/18] =?UTF-8?q?test(game-scene):=20extracci=C3=B3=20Room?= =?UTF-8?q?Fsm=20i=20smoke=20test=20de=20la=20FSM=20de=20sala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 25 ++++ source/game/scenes/game_scene.cpp | 166 ++------------------- source/game/scenes/game_scene.hpp | 72 ++------- source/game/scenes/room_fsm.cpp | 131 ++++++++++++++++ source/game/scenes/room_fsm.hpp | 70 +++++++++ tests/test_game_fsm.cpp | 240 ++++++++++++++++++++++++++++++ 6 files changed, 493 insertions(+), 211 deletions(-) create mode 100644 source/game/scenes/room_fsm.cpp create mode 100644 source/game/scenes/room_fsm.hpp create mode 100644 tests/test_game_fsm.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a6a777c..70df771 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,7 @@ set(APP_SOURCES 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/entities/player.cpp source/game/map/map.cpp source/game/map/tilemap.cpp @@ -374,4 +375,28 @@ if(BUILD_TESTS) add_test(NAME tilemap_writer COMMAND test_tilemap_writer WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + + # Smoke de la FSM de sala (RoomFsm). Cobreix loadConfig (timings i + # corbes des de YAML), advance (transicions entre estats), i + # formatRoomTitle (locale + substitució `{n}`). Stand-alone: no + # arrossega Audio/Screen/Player/EntityManager — només els helpers + # purs de la FSM, Locale i UiConfig. + add_executable(test_game_fsm + tests/test_game_fsm.cpp + source/game/scenes/room_fsm.cpp + source/core/locale/locale.cpp + source/core/ui/ui_config.cpp + source/core/resources/resource_helper.cpp + source/core/resources/resource_pack.cpp + source/utils/easing.cpp + ) + target_include_directories(test_game_fsm PRIVATE + "${CMAKE_SOURCE_DIR}/source" + "${CMAKE_BINARY_DIR}" + ) + target_link_libraries(test_game_fsm PRIVATE SDL3::SDL3) + target_compile_options(test_game_fsm PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME game_fsm + COMMAND test_game_fsm + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) endif() diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index bc7e04e..cc9f4f4 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -3,14 +3,12 @@ #include #include -#include #include #include #include #include #include #include -#include #include #include #include @@ -77,63 +75,6 @@ namespace { return yaml[key].get_value(); } - // Llegeix un sub-camp sencer del bloc `parent` (o el fallback) sense - // imprimir res. Pensat per a la lectura puntual del data/scenes/game.yaml. - auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int { - if (!parent.contains(key)) { return fallback; } - return parent[key].get_value(); - } - - // Carrega `data/scenes/game.yaml` i emplena un SceneTransitionConfig. - // Si el fitxer falta o algun camp no hi és, els valors de fallback - // que ja porta l'estructura es conserven. Les dues corbes es resolen - // per nom amb `UiConfig::curveByName` (mateix mecanisme que la pausa); - // si el nom és desconegut, cau a `Easing::linear` amb warning al log. - auto readSceneConfig(const std::string& yaml_path) -> GameScene::SceneTransitionConfig { - GameScene::SceneTransitionConfig cfg{}; - cfg.text_enter_curve = UiConfig::curveByName("outCubic"); - cfg.text_exit_curve = UiConfig::curveByName("inCubic"); - - auto bytes = Resource::Helper::loadFile(yaml_path); - if (bytes.empty()) { - std::cout << "GameScene: " << yaml_path << " absent — uso fallbacks compilats\n"; - 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("room_intro")) { - const auto& n = yaml["room_intro"]; - cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms); - cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms); - cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms); - cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms); - cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms); - } - if (yaml.contains("room_outro")) { - const auto& n = yaml["room_outro"]; - cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms); - cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms); - cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms); - cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms); - } - if (yaml.contains("text")) { - const auto& n = yaml["text"]; - if (n.contains("enter_curve")) { - cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value()); - } - if (n.contains("exit_curve")) { - cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value()); - } - } - - std::cout << "GameScene: timings carregats des de " << yaml_path - << " (intro fade " << cfg.intro_fade_in_ms - << "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n"; - return cfg; - } - // Comprova si `a` i `b` se solapen amb una tolerància positiva al // contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja // ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa @@ -329,7 +270,7 @@ GameScene::GameScene(Config config) // transició ja en marxa. entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) { std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n'; - if (state_ == State::PLAYING) { setState(State::ROOM_OUTRO); } + if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); } }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); @@ -460,7 +401,7 @@ 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_ = readSceneConfig(Defaults::GameScene::CONFIG_YAML); + transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::CONFIG_YAML); // Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita // les transicions són instantànies (sense fades ni text) i el primer @@ -470,95 +411,16 @@ GameScene::GameScene(Config config) GameScene::~GameScene() = default; -namespace { - // Nom textual d'un State per als logs de transició. L'enum és privat - // a GameScene, així que la funció ha de viure aquí com a lambda - // local del setState — la deixem com a constexpr que rep l'enum - // intern via auto-deducció a partir del seu valor entrant. - constexpr auto stateName(GameScene::State s) -> const char* { - using S = GameScene::State; - switch (s) { - case S::ROOM_INTRO: - return "ROOM_INTRO"; - case S::PLAYING: - return "PLAYING"; - case S::ROOM_OUTRO: - return "ROOM_OUTRO"; - } - return "?"; - } -} // namespace - -void GameScene::setState(State new_state) { +void GameScene::setState(RoomFsm::State new_state) { if (new_state == state_) { return; } - std::cout << "[ROOM] " << stateName(state_) << " -> " << stateName(new_state) << '\n'; + std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n'; state_ = new_state; - transition_ = {}; // sub-fase i elapsed s'inicialitzen a zero a cada estat. + transition_ = {}; } void GameScene::updateRoomTransition(int delta_ms) { - if (state_ == State::PLAYING) { return; } - - transition_.elapsed_ms += delta_ms; - - // Avança totes les sub-fases que càpiguen al delta acumulat (típicament - // una, però si el delta és gran o la sub-fase és molt curta podria - // saltar més d'una en un sol frame). En sortir del bucle, o bé estem - // dins d'una sub-fase amb temps restant, o bé hem consumit totes les - // sub-fases de l'estat i toca canviar d'estat. - if (state_ == State::ROOM_INTRO) { - const std::array DURS = { - transition_config_.intro_fade_in_ms, - transition_config_.intro_text_delay_ms, - transition_config_.intro_text_enter_ms, - transition_config_.intro_text_hold_ms, - transition_config_.intro_text_exit_ms, - }; - while (transition_.phase < static_cast(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) { - transition_.elapsed_ms -= DURS[transition_.phase]; - ++transition_.phase; - } - if (transition_.phase >= static_cast(DURS.size())) { - setState(State::PLAYING); - } - return; - } - if (state_ == State::ROOM_OUTRO) { - const std::array DURS = { - transition_config_.outro_text_enter_ms, - transition_config_.outro_text_hold_ms, - transition_config_.outro_text_exit_ms, - transition_config_.outro_fade_out_ms, - }; - while (transition_.phase < static_cast(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) { - transition_.elapsed_ms -= DURS[transition_.phase]; - ++transition_.phase; - } - if (transition_.phase >= static_cast(DURS.size())) { - // Placeholder fins a l'itinerari de sales: tornem a la mateixa - // sala. No es reinicia res — porta + sarcòfags continuen a - // l'estat OPEN, com era l'acord. - // TODO(itinerari): seleccionar la pròxima sala (incrementar - // `room_index_`, intercanviar `config_.map_yaml`, recrear - // tilemap/entitats) abans de tornar a ROOM_INTRO. - setState(State::ROOM_INTRO); - } - return; - } -} - -auto GameScene::formatRoomTitle(int n) -> std::string { - // Substitució mínima `{n}` → enter. Locale::text retorna la pròpia - // clau si no troba traducció, així que quan els fitxers de locale - // encara no porten la clau el text serà visible com a "game.room_intro" - // i no com una cadena buida (útil per a debug en pre-locales). - auto tpl = Locale::get().text("game.room_intro"); - constexpr std::string_view PLACEHOLDER = "{n}"; - auto pos = tpl.find(PLACEHOLDER); - if (pos != std::string::npos) { - tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n)); - } - return tpl; + const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); + if (NEXT != state_) { setState(NEXT); } } void GameScene::renderRoomFade() { @@ -569,10 +431,10 @@ void GameScene::renderRoomFade() { int dur = 0; float t = 0.0F; bool fade_in = false; - if (state_ == State::ROOM_INTRO && transition_.phase == 0) { + if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase == 0) { dur = transition_config_.intro_fade_in_ms; fade_in = true; - } else if (state_ == State::ROOM_OUTRO && transition_.phase == 3) { + } else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 3) { dur = transition_config_.outro_fade_out_ms; fade_in = false; } else { @@ -603,13 +465,13 @@ void GameScene::renderRoomText() { int phase_exit = 0; int dur_enter = 0; int dur_exit = 0; - if (state_ == State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { - text = formatRoomTitle(room_index_); + if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { + text = RoomFsm::formatRoomTitle(room_index_); phase_enter = 2; phase_exit = 4; dur_enter = transition_config_.intro_text_enter_ms; dur_exit = transition_config_.intro_text_exit_ms; - } else if (state_ == State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { + } else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { text = Locale::get().text("game.room_complete"); phase_enter = 0; phase_exit = 2; @@ -796,7 +658,7 @@ void GameScene::update(int delta_ms) { constexpr bool EDITOR_FROZEN = false; #endif const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); - const bool GAMEPLAY_ACTIVE = (state_ == State::PLAYING); + const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING); if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; @@ -873,7 +735,7 @@ void GameScene::render() { // Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen. // Les entitats sí que es renderitzen perquè el disseny demana que // el món continui animant-se durant el fade i el text. - const bool RENDER_PLAYERS = (state_ == State::PLAYING); + const bool RENDER_PLAYERS = (state_ == RoomFsm::State::PLAYING); if (RENDER_PLAYERS) { // Passada de sombres dels jugadors saltant. Va per davant del diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 50b73a4..1f85996 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -11,6 +11,7 @@ #include "game/entities/entity_manager.hpp" #include "game/map/paint_layer.hpp" #include "game/map/tilemap.hpp" +#include "game/scenes/room_fsm.hpp" #ifdef _DEBUG #include "game/editor/editor.hpp" @@ -38,39 +39,6 @@ class GameScene : public Scene { [[nodiscard]] static auto defaultConfig() -> Config; - // Estat de l'escena dins del cicle d'una sala. La màquina és interna a - // GameScene: SceneManager segueix tractant GameScene com una sola - // escena. ROOM_INTRO i ROOM_OUTRO bloquegen input i render dels - // jugadors; l'EntityManager segueix avançant i el HUD/Notifier també. - // L'enum es declara públic perquè els helpers de log (stateName) i - // tests viuen fora de la classe; el camp `state_` segueix privat. - enum class State : std::uint8_t { - ROOM_INTRO, // Entrada de sala: fade in + text d'entrada. - PLAYING, // Joc normal. - ROOM_OUTRO, // Sortida de sala: text de sortida + fade out. - }; - - // Configuració de la FSM de sala llegida de `data/scenes/game.yaml`. - // Si el YAML manca, els camps queden amb els fallbacks compilats. Es - // carrega un sol cop al ctor; canvis al fitxer requereixen reiniciar. - // Pública pel mateix motiu que State: el reader viu en un anonymous - // namespace fora de la classe per simetria amb els altres readers. - struct SceneTransitionConfig { - int intro_fade_in_ms{500}; - int intro_text_delay_ms{500}; - int intro_text_enter_ms{500}; - int intro_text_hold_ms{1000}; - int intro_text_exit_ms{500}; - - int outro_text_enter_ms{500}; - int outro_text_hold_ms{1000}; - int outro_text_exit_ms{500}; - int outro_fade_out_ms{500}; - - UiConfig::CurveFn text_enter_curve{nullptr}; - UiConfig::CurveFn text_exit_curve{nullptr}; - }; - explicit GameScene(Config config = defaultConfig()); ~GameScene() override; @@ -78,16 +46,15 @@ class GameScene : public Scene { void render() override; private: - // Punt centralitzat per canviar d'estat. Imprimeix la transició i - // reinicialitza els acumuladors de la transició de sala. Cap estat - // s'ha de canviar fora d'aquí. - void setState(State new_state); + // Punt centralitzat per canviar d'estat de sala. Imprimeix la + // transició i reinicialitza els acumuladors de la `RoomFsm::Transition`. + // Cap estat s'ha de canviar fora d'aquí. + void setState(RoomFsm::State new_state); - // Avança la transició activa (sub-fases, timings). En aquesta fita - // les transicions són instantànies (sense efectes); les sub-fases - // amb fades i text arribaran als commits següents. S'executa cada - // frame amb delta real (no escalat per la pausa) perquè la - // transició viu en temps de UI. + // Avança la transició activa via `RoomFsm::advance`. S'executa cada + // frame amb delta real (no escalat per la pausa) perquè la transició + // viu en temps de UI. Si l'estat retornat és diferent de l'actual, + // crida `setState` (que reseteja la `Transition`). void updateRoomTransition(int delta_ms); // Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó @@ -178,22 +145,16 @@ class GameScene : public Scene { // Configuració de la FSM de sala (intro/outro). Carregada al ctor des // de `data/scenes/game.yaml`; valors per defecte si manca el YAML. - SceneTransitionConfig transition_config_{}; + RoomFsm::Config transition_config_{}; // Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor // perquè cada vegada que entres al joc passes per la presentació de // la sala. La transició a PLAYING l'orquestra `updateRoomTransition`. - State state_{State::ROOM_INTRO}; + RoomFsm::State state_{RoomFsm::State::ROOM_INTRO}; // Sub-fase i temps acumulat dins de l'estat actual. `setState` el - // reseteja a {0, 0}. La interpretació depèn de l'estat: - // ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit - // ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out - // PLAYING: no s'usa (updateRoomTransition no avança res). - struct RoomTransition { - int phase{0}; - int elapsed_ms{0}; - } transition_{}; + // reseteja a {0, 0}. + RoomFsm::Transition transition_{}; // Índex de sala que es passa com a paràmetre {n} al text d'intro. // Avui és sempre 1 (no hi ha itinerari de sales encara). Quan @@ -201,13 +162,6 @@ class GameScene : public Scene { // de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima. int room_index_{1}; - // Substitueix `{n}` de `Locale::get().text("game.room_intro")` per - // l'enter passat. Pensat com a primer pas mínim cap a un format - // genèric: si apareix més text parametritzat, val la pena ascendir - // a un `Locale::format` propi. Estàtica perquè no depèn d'estat de - // l'escena (només consulta el Locale singleton). - [[nodiscard]] static auto formatRoomTitle(int n) -> std::string; - // Píxels del fade negre i del text de la transició de sala. // Cridats des de `render()` entre el bloc d'actors i el HUD — // així marcador, notificacions i service menu queden per damunt. diff --git a/source/game/scenes/room_fsm.cpp b/source/game/scenes/room_fsm.cpp new file mode 100644 index 0000000..cbc1614 --- /dev/null +++ b/source/game/scenes/room_fsm.cpp @@ -0,0 +1,131 @@ +#include "game/scenes/room_fsm.hpp" + +#include +#include +#include + +#include "core/locale/locale.hpp" +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" + +namespace { + + // Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el + // fallback. Dissenyat per al consum puntual de `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 RoomFsm { + + auto loadConfig(const std::string& yaml_path) -> Config { + Config cfg{}; + cfg.text_enter_curve = UiConfig::curveByName("outCubic"); + cfg.text_exit_curve = UiConfig::curveByName("inCubic"); + + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { + std::cout << "RoomFsm: " << yaml_path << " absent — uso fallbacks compilats\n"; + 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("room_intro")) { + const auto& n = yaml["room_intro"]; + cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms); + cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms); + cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms); + cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms); + cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms); + } + if (yaml.contains("room_outro")) { + const auto& n = yaml["room_outro"]; + cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms); + cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms); + cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms); + cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms); + } + if (yaml.contains("text")) { + const auto& n = yaml["text"]; + if (n.contains("enter_curve")) { + cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value()); + } + if (n.contains("exit_curve")) { + cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value()); + } + } + + std::cout << "RoomFsm: timings carregats des de " << yaml_path + << " (intro fade " << cfg.intro_fade_in_ms + << "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n"; + return cfg; + } + + auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State { + if (current == State::PLAYING) { return current; } + + t.elapsed_ms += delta_ms; + + if (current == State::ROOM_INTRO) { + const std::array DURS = { + cfg.intro_fade_in_ms, + cfg.intro_text_delay_ms, + cfg.intro_text_enter_ms, + cfg.intro_text_hold_ms, + cfg.intro_text_exit_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::PLAYING; } + return current; + } + + // ROOM_OUTRO + const std::array DURS = { + cfg.outro_text_enter_ms, + cfg.outro_text_hold_ms, + cfg.outro_text_exit_ms, + cfg.outro_fade_out_ms, + }; + while (t.phase < static_cast(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { + t.elapsed_ms -= DURS[t.phase]; + ++t.phase; + } + // Placeholder fins a l'itinerari de sales: l'OUTRO recau a INTRO. + // TODO(itinerari): aquest mateix retorn sortirà cap a una nova + // sala (incrementant `room_index_` i recreant tilemap/entitats al + // GameScene) abans de tornar a ROOM_INTRO. + if (t.phase >= static_cast(DURS.size())) { return State::ROOM_INTRO; } + return current; + } + + auto formatRoomTitle(int n) -> std::string { + auto tpl = Locale::get().text("game.room_intro"); + constexpr std::string_view PLACEHOLDER = "{n}"; + auto pos = tpl.find(PLACEHOLDER); + if (pos != std::string::npos) { + tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n)); + } + return tpl; + } + + auto stateName(State s) -> const char* { + switch (s) { + case State::ROOM_INTRO: + return "ROOM_INTRO"; + case State::PLAYING: + return "PLAYING"; + case State::ROOM_OUTRO: + return "ROOM_OUTRO"; + } + return "?"; + } + +} // namespace RoomFsm diff --git a/source/game/scenes/room_fsm.hpp b/source/game/scenes/room_fsm.hpp new file mode 100644 index 0000000..5b72a07 --- /dev/null +++ b/source/game/scenes/room_fsm.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "core/ui/ui_config.hpp" + +// FSM de sala (intro/outro) extreta de GameScene per a testabilitat. La +// lògica que viu aquí és pura: no toca SDL, ni Audio, ni cap singleton +// excepte `Locale::get()` (per a la formatació del text). El consumidor +// actual és `GameScene`, que cridarà `RoomFsm::advance` cada frame. +namespace RoomFsm { + + enum class State : std::uint8_t { + ROOM_INTRO, // Entrada de sala: fade in + text d'entrada. + PLAYING, // Joc normal. + ROOM_OUTRO, // Sortida de sala: text de sortida + fade out. + }; + + // Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a + // {0, 0} quan es canvia d'estat. Significat de `phase` per estat: + // ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit + // ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out + // PLAYING: no s'usa. + struct Transition { + int phase{0}; + int elapsed_ms{0}; + }; + + // Timings i corbes carregats de `data/scenes/game.yaml`. Els valors + // per defecte coincideixen amb els del YAML versionat: si manca el + // fitxer, la FSM funciona igualment amb aquests fallbacks. + struct Config { + int intro_fade_in_ms{500}; + int intro_text_delay_ms{500}; + int intro_text_enter_ms{500}; + int intro_text_hold_ms{1000}; + int intro_text_exit_ms{500}; + + int outro_text_enter_ms{500}; + int outro_text_hold_ms{1000}; + int outro_text_exit_ms{500}; + int outro_fade_out_ms{500}; + + UiConfig::CurveFn text_enter_curve{nullptr}; + UiConfig::CurveFn text_exit_curve{nullptr}; + }; + + // Llegeix la configuració des del YAML. Si el fitxer manca o falten + // claus, els camps no presents conserven els seus fallbacks. Les + // corbes es resolen amb `UiConfig::curveByName` (mateix mecanisme + // que la pausa); si el nom és desconegut cau a `Easing::linear`. + [[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config; + + // Avança la transició consumint `delta_ms` des del temps real. + // Retorna l'estat resultant (potser el mateix que l'entrant si + // encara queden sub-fases). Si retorna un estat diferent, el caller + // ha de resetejar la `Transition` a {0, 0} abans del següent frame + // (això ho fa `GameScene::setState`). Funció pura sobre l'argument. + [[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State; + + // Substitueix `{n}` de la clau "game.room_intro" del Locale per + // l'enter passat. Si la clau no està traduïda, retorna la pròpia + // clau (fallback de Locale::text). + [[nodiscard]] auto formatRoomTitle(int n) -> std::string; + + // Nom textual per a logs i tests. No es fa servir per a lògica. + [[nodiscard]] auto stateName(State s) -> const char*; + +} // namespace RoomFsm diff --git a/tests/test_game_fsm.cpp b/tests/test_game_fsm.cpp new file mode 100644 index 0000000..bf2a638 --- /dev/null +++ b/tests/test_game_fsm.cpp @@ -0,0 +1,240 @@ +// Smoke test de la FSM de sala (RoomFsm) extreta de GameScene. Cobreix: +// T1 loadConfig — fitxer real → tots els camps llegits. +// T2 loadConfig — fitxer absent → fallbacks compilats; corbes default +// resoltes per UiConfig::curveByName. +// T3 loadConfig — claus parcials → camps absents conserven fallback. +// T4 advance — ROOM_INTRO consumeix les 5 sub-fases en ordre. +// T5 advance — ROOM_INTRO desemboca a PLAYING quan acaba l'última fase. +// T6 advance — PLAYING és sumidor: no avança res ni canvia d'estat. +// T7 advance — ROOM_OUTRO consumeix les 4 sub-fases i recau a ROOM_INTRO. +// T8 advance — un delta gran salta més d'una sub-fase en un sol pas. +// T9 formatRoomTitle — substitueix `{n}` quan el Locale té la clau. +// T10 formatRoomTitle — sense Locale, retorna la pròpia clau (fallback). +// T11 stateName — strings esperats per a cada State. +// +// El render no es testeja: la FSM és pura i no pinta res. Les regles de +// gating d'input dels jugadors viuen a `GameScene::update` i estan +// cobertes pel smoke run del binari (xvfb-run) — la part testable és +// que `advance` retorna l'estat correcte, que és el senyal que activa +// el gating al callsite. +// +// Execució: +// make build && cd build && ctest --output-on-failure +// O bé: +// ./build/test_game_fsm + +#include +#include +#include +#include +#include + +#include "core/locale/locale.hpp" +#include "core/ui/ui_config.hpp" +#include "game/scenes/room_fsm.hpp" + +namespace { + + int g_failures = 0; + + void check(bool cond, const char* msg, const char* file, int line) { + if (!cond) { + std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n"; + ++g_failures; + } + } + +#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__) + + auto writeTemp(const std::string& path, const std::string& content) -> std::string { + std::ofstream file(path); + file << content; + return path; + } + + void testLoadConfigFull() { + std::cout << "T1 loadConfig — fitxer real complet\n"; + const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_full.yaml").string(); + writeTemp(PATH, + "room_intro:\n" + " fade_in_ms: 100\n" + " text_delay_ms: 200\n" + " text_enter_ms: 300\n" + " text_hold_ms: 400\n" + " text_exit_ms: 500\n" + "room_outro:\n" + " text_enter_ms: 600\n" + " text_hold_ms: 700\n" + " text_exit_ms: 800\n" + " fade_out_ms: 900\n" + "text:\n" + " enter_curve: outQuad\n" + " exit_curve: inQuad\n"); + const auto CFG = RoomFsm::loadConfig(PATH); + CHECK(CFG.intro_fade_in_ms == 100); + CHECK(CFG.intro_text_delay_ms == 200); + CHECK(CFG.intro_text_enter_ms == 300); + CHECK(CFG.intro_text_hold_ms == 400); + CHECK(CFG.intro_text_exit_ms == 500); + CHECK(CFG.outro_text_enter_ms == 600); + CHECK(CFG.outro_text_hold_ms == 700); + CHECK(CFG.outro_text_exit_ms == 800); + CHECK(CFG.outro_fade_out_ms == 900); + CHECK(CFG.text_enter_curve == UiConfig::curveByName("outQuad")); + CHECK(CFG.text_exit_curve == UiConfig::curveByName("inQuad")); + std::filesystem::remove(PATH); + } + + void testLoadConfigMissing() { + std::cout << "T2 loadConfig — fitxer absent (fallbacks)\n"; + const auto CFG = RoomFsm::loadConfig("/tmp/aee_2026_does_not_exist.yaml"); + CHECK(CFG.intro_fade_in_ms == 500); + CHECK(CFG.intro_text_delay_ms == 500); + CHECK(CFG.intro_text_enter_ms == 500); + CHECK(CFG.intro_text_hold_ms == 1000); + CHECK(CFG.intro_text_exit_ms == 500); + CHECK(CFG.outro_text_enter_ms == 500); + CHECK(CFG.outro_text_hold_ms == 1000); + CHECK(CFG.outro_text_exit_ms == 500); + CHECK(CFG.outro_fade_out_ms == 500); + CHECK(CFG.text_enter_curve != nullptr); + CHECK(CFG.text_exit_curve != nullptr); + } + + void testLoadConfigPartial() { + std::cout << "T3 loadConfig — claus parcials (mantenir fallback)\n"; + const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_partial.yaml").string(); + writeTemp(PATH, + "room_intro:\n" + " fade_in_ms: 42\n"); + const auto CFG = RoomFsm::loadConfig(PATH); + CHECK(CFG.intro_fade_in_ms == 42); + CHECK(CFG.intro_text_delay_ms == 500); // fallback + CHECK(CFG.outro_fade_out_ms == 500); // fallback + std::filesystem::remove(PATH); + } + + auto makeShortConfig() -> RoomFsm::Config { + // Durades curtes per fer els tests d'`advance` deterministes i + // independents dels valors per defecte. + RoomFsm::Config cfg{}; + cfg.intro_fade_in_ms = 10; + cfg.intro_text_delay_ms = 20; + cfg.intro_text_enter_ms = 30; + cfg.intro_text_hold_ms = 40; + cfg.intro_text_exit_ms = 50; + cfg.outro_text_enter_ms = 11; + cfg.outro_text_hold_ms = 22; + cfg.outro_text_exit_ms = 33; + cfg.outro_fade_out_ms = 44; + return cfg; + } + + void testAdvanceIntroPhases() { + std::cout << "T4 advance — ROOM_INTRO consumeix sub-fases en ordre\n"; + const auto CFG = makeShortConfig(); + RoomFsm::Transition t{}; + auto s = RoomFsm::State::ROOM_INTRO; + + s = RoomFsm::advance(s, t, CFG, 5); // dins fase 0 + CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 0 && t.elapsed_ms == 5); + + s = RoomFsm::advance(s, t, CFG, 10); // surt de fase 0, entra a fase 1 (5 ms dins) + CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 1 && t.elapsed_ms == 5); + + s = RoomFsm::advance(s, t, CFG, 20); // surt de fase 1 (15 dins), entra a fase 2 amb 5 ms (5+20-20=5) + CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 2 && t.elapsed_ms == 5); + } + + void testAdvanceIntroToPlaying() { + std::cout << "T5 advance — ROOM_INTRO acaba a PLAYING\n"; + const auto CFG = makeShortConfig(); + RoomFsm::Transition t{}; + const int TOTAL = CFG.intro_fade_in_ms + CFG.intro_text_delay_ms + + CFG.intro_text_enter_ms + CFG.intro_text_hold_ms + + CFG.intro_text_exit_ms; + const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, TOTAL); + CHECK(S == RoomFsm::State::PLAYING); + } + + void testAdvancePlayingNoOp() { + std::cout << "T6 advance — PLAYING és sumidor\n"; + const auto CFG = makeShortConfig(); + RoomFsm::Transition t{42, 999}; + const auto S = RoomFsm::advance(RoomFsm::State::PLAYING, t, CFG, 1000); + CHECK(S == RoomFsm::State::PLAYING); + CHECK(t.phase == 42); // intacte + CHECK(t.elapsed_ms == 999); + } + + void testAdvanceOutroToIntro() { + std::cout << "T7 advance — ROOM_OUTRO recau a ROOM_INTRO\n"; + const auto CFG = makeShortConfig(); + RoomFsm::Transition t{}; + const int TOTAL = CFG.outro_text_enter_ms + CFG.outro_text_hold_ms + + CFG.outro_text_exit_ms + CFG.outro_fade_out_ms; + const auto S = RoomFsm::advance(RoomFsm::State::ROOM_OUTRO, t, CFG, TOTAL); + CHECK(S == RoomFsm::State::ROOM_INTRO); + } + + void testAdvanceLargeDeltaJumps() { + std::cout << "T8 advance — un delta gran salta múltiples sub-fases\n"; + const auto CFG = makeShortConfig(); + RoomFsm::Transition t{}; + // 10+20+5 = 35 ms → consumeix fase 0 i 1, queda a fase 2 amb 5 ms. + const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, 35); + CHECK(S == RoomFsm::State::ROOM_INTRO); + CHECK(t.phase == 2); + CHECK(t.elapsed_ms == 5); + } + + void testFormatRoomTitleWithLocale() { + std::cout << "T9 formatRoomTitle — substitució amb Locale\n"; + const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale.yaml").string(); + writeTemp(PATH, "game:\n room_intro: \"Sala {n}\"\n"); + Locale::get().load(PATH); + CHECK(RoomFsm::formatRoomTitle(1) == "Sala 1"); + CHECK(RoomFsm::formatRoomTitle(7) == "Sala 7"); + std::filesystem::remove(PATH); + } + + void testFormatRoomTitleNoLocale() { + std::cout << "T10 formatRoomTitle — fallback sense traducció\n"; + const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale_empty.yaml").string(); + writeTemp(PATH, "other: foo\n"); + Locale::get().load(PATH); + // Sense la clau "game.room_intro", Locale retorna la pròpia clau, + // i com que no porta `{n}`, la substitució no canvia res. + CHECK(RoomFsm::formatRoomTitle(3) == "game.room_intro"); + std::filesystem::remove(PATH); + } + + void testStateName() { + std::cout << "T11 stateName — strings esperats\n"; + CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_INTRO)) == "ROOM_INTRO"); + CHECK(std::string(RoomFsm::stateName(RoomFsm::State::PLAYING)) == "PLAYING"); + CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_OUTRO)) == "ROOM_OUTRO"); + } + +} // namespace + +auto main() -> int { + testLoadConfigFull(); + testLoadConfigMissing(); + testLoadConfigPartial(); + testAdvanceIntroPhases(); + testAdvanceIntroToPlaying(); + testAdvancePlayingNoOp(); + testAdvanceOutroToIntro(); + testAdvanceLargeDeltaJumps(); + testFormatRoomTitleWithLocale(); + testFormatRoomTitleNoLocale(); + testStateName(); + + if (g_failures == 0) { + std::cout << "OK: tots els tests de RoomFsm passen\n"; + return 0; + } + std::cerr << g_failures << " tests han fallat\n"; + return 1; +} From b54f3fc62265697b93508ae32a8f602da0943a77 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:05:32 +0200 Subject: [PATCH 07/18] =?UTF-8?q?feat(map):=20door=5Fcol=20a=20la=20geomet?= =?UTF-8?q?ria=20+=20passad=C3=ADs=20de=20sortida=20a=20Map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/maps/pyramid.yaml | 6 ++++++ source/game/map/map.cpp | 19 +++++++++++++++++++ source/game/map/map.hpp | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 13f5bfd..6111eab 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -42,6 +42,12 @@ geometry: hud_h: 24 back_wall_h: 24 side_wall_w: 24 + # Columna central del tile transitable de la porta. La porta es renderitza + # com a sprite 48×32 (3 tiles d'amplada) centrat sobre aquesta columna; els + # 16 px centrals (just aquesta columna) són el "forat" del mur trasser per + # on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent + # mur visible. El spawn dels jugadors també s'alinea amb aquesta columna. + door_col: 10 data: - [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index 12c1048..f199505 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -75,9 +75,24 @@ namespace Map { playfield_w = board_w + (2 * outer_corridor); playfield_h = board_h + (2 * outer_corridor); + // Porta i passadís de sortida. `door_col` és la columna central + // transitable; el passadís són exactament els 16 px d'eixa columna + // dins del strip del mur trasser. Si manca la clau, `door_col=0` + // i el passadís queda a x=0 amb mida zero — pràcticament inactiu + // (el bloqueig del clamp continua sent el rectangle del playfield). + door_col = g.contains("door_col") ? g["door_col"].get_value() : 0; + door_corridor_x = (door_col * tile_size) + tilemap_origin_x; + door_corridor_y = hud_h; + door_corridor_w = tile_size; + door_corridor_h = back_wall_h; + // Cada càrrega de nivell deixa la porta tancada; l'entitat Door + // canviarà el flag a true quan transiti a OPEN. + door_open = false; + std::cout << "Map: geometria " << grid_cols << "x" << grid_rows << " sarcs " << sarc_w << "x" << sarc_h << " pitch " << pitch_x << "x" << pitch_y + << " door_col " << door_col << " des de " << yaml_path << '\n'; } @@ -136,6 +151,10 @@ namespace Map { .tile_size = tile_size, .tilemap_origin_x = tilemap_origin_x, .tilemap_origin_y = tilemap_origin_y, + .door_corridor_x = door_corridor_x, + .door_corridor_y = door_corridor_y, + .door_corridor_w = door_corridor_w, + .door_corridor_h = door_corridor_h, .collides = &collides, .inside_playfield = &insidePlayfield, }; diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index 895469f..62cf463 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -62,6 +62,24 @@ namespace Map { inline int playfield_w = 0; inline int playfield_h = 0; + // Porta de sortida del nivell. La porta sempre viu al mur trasser + // (`wall_north`); només la columna varia per nivell. `door_col` és la + // columna central transitable: el sprite 48×32 es dibuixa centrat + // sobre aquesta columna i els 16 px centrals (just `door_col`) són + // l'únic forat del mur trasser per on el jugador surt. Derivat de + // `geometry.door_col` al YAML. + inline int door_col = 0; + inline int door_corridor_x = 0; + inline int door_corridor_y = 0; + inline int door_corridor_w = 0; + inline int door_corridor_h = 0; + // Estat del passadís: si la porta està oberta, el `door_corridor` no + // bloqueja el moviment del jugador (vegeu `Map::collides`). Qui muta + // aquest flag és l'entitat `Door` quan transita CLOSED → OPEN; el + // reset el porta `Map::loadFrom` (cada càrrega de nivell el torna a + // posar a false). + inline bool door_open = false; + // Carrega el bloc `geometry:` del YAML i recalcula tots els derivats. // Ha de cridar-se abans d'instanciar `Player` o consultar `collides()`: // GameScene ho fa al ctor. @@ -104,6 +122,13 @@ namespace Map { int tile_size{0}; int tilemap_origin_x{0}; int tilemap_origin_y{0}; + // Passadís de sortida (forat del mur trasser a la columna de la + // porta). El Player l'usa des del clamp per permetre pujar al + // strip del mur quan la base box queda dins del rang X. + int door_corridor_x{0}; + int door_corridor_y{0}; + int door_corridor_w{0}; + int door_corridor_h{0}; bool (*collides)(const SDL_FRect&){nullptr}; bool (*inside_playfield)(const SDL_FRect&){nullptr}; }; From 648a5932f82da9a8d1119e84144be79c91d45437 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:07:37 +0200 Subject: [PATCH 08/18] =?UTF-8?q?feat(door):=20col=C2=B7locaci=C3=B3=20der?= =?UTF-8?q?ivada=20de=20Map::door=5Fcol=20i=20caixa=20de=20col=C2=B7lisi?= =?UTF-8?q?=C3=B3=20neta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/entities/door/door.yaml | 8 ++++ data/maps/pyramid.yaml | 4 +- .../entities/entity_manager_loadlevel.cpp | 48 +++++++++++++++---- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml index fabd3d8..0b041e0 100644 --- a/data/entities/door/door.yaml +++ b/data/entities/door/door.yaml @@ -5,6 +5,14 @@ sprite: size: [48, 32] tile_size: [3, 2] collision: + # Caixa de col·lisió: 16×24 centrada al sprite 48×32. Coincideix amb el + # tile central transitable (els 16 px del passadís de sortida) i es + # restringeix a la franja del mur trasser (24 px d'alçada). Així + # `checkPlayerCollisions` només dispara `onPlayerContact` quan el + # jugador entra al forat real, no als marcs flanquejants ni als 8 px + # superiors que queden tapats pel HUD. + size: [16, 24] + offset: [16, 8] # Porta = mur infranquejable. Igual que el sarc, alçada efectiva # infinita per impedir que un jugador la salte per damunt. height: 999 diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 6111eab..602b4da 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -129,7 +129,9 @@ entities: - type: sarcophagus sarc_position: [3, 4] - type: door - position: [9, 1] + # Sense `position`: la porta sempre viu al mur trasser. La columna + # transitable la dicta `geometry.door_col`; el render i la caixa + # de col·lisió es deriven d'aquesta dada al loadFromLevel. - type: mummy position: [10, 7] - type: mummy diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index c4b1cc7..ee0f0ef 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -291,6 +291,34 @@ namespace { return true; } + // Col·loca la porta al mur trasser segons `Map::door_col`. La porta + // viu sempre al `wall_north`; només la columna varia per nivell. El + // sprite (típicament 48×32) es centra horitzontalment sobre la + // columna transitable i s'alinea verticalment de manera que la seua + // vora inferior coincideixi amb la base del strip del mur trasser + // (= `playfield_y`). Amb sprite 48×32 i `back_wall_h = 24` això vol + // dir que els 8 px superiors del sprite queden dins del HUD i hi + // queden tapats — comportament intencional. + auto placeDoor(EntityManager& manager, std::unique_ptr entity, const SpriteInfo& sprite) -> bool { + if (Map::door_col <= 0 || Map::tile_size <= 0) { + std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n"; + return false; + } + const float CENTER_X = static_cast((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast(Map::tile_size) / 2.0F); + const SDL_FRect RENDER{ + .x = CENTER_X - (sprite.width / 2.0F), + .y = static_cast(Map::playfield_y) - sprite.height, + .w = sprite.width, + .h = sprite.height, + }; + entity->setPixelRect(RENDER); + + auto* door_ptr = dynamic_cast(entity.get()); + manager.add(std::move(entity)); + if (door_ptr != nullptr) { manager.setDoor(door_ptr); } + return true; + } + auto placeGeneric(EntityManager& manager, std::unique_ptr entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool { if (!spawn.contains("position")) { std::cerr << "EntityManager: " << type << " sense position\n"; @@ -322,13 +350,7 @@ namespace { } entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); - if (type == "door") { - auto* door_ptr = dynamic_cast(entity.get()); - manager.add(std::move(entity)); - if (door_ptr != nullptr) { manager.setDoor(door_ptr); } - } else { - manager.add(std::move(entity)); - } + manager.add(std::move(entity)); return true; } } // namespace @@ -346,9 +368,15 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { auto entity = buildEntity(TYPE, sprite, anchor); if (entity == nullptr) { continue; } - const bool OK = (TYPE == "sarcophagus") - ? placeSarcophagus(*this, std::move(entity), sprite, spawn) - : placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); + bool ok = false; + if (TYPE == "sarcophagus") { + ok = placeSarcophagus(*this, std::move(entity), sprite, spawn); + } else if (TYPE == "door") { + ok = placeDoor(*this, std::move(entity), sprite); + } else { + ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); + } + const bool OK = ok; if (OK) { ++loaded; } } From 377e5d0b975604d3fda81518897a0a2d48e27724 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:09:04 +0200 Subject: [PATCH 09/18] =?UTF-8?q?feat(player):=20clamp=20permet=20pujar=20?= =?UTF-8?q?al=20passad=C3=ADs=20+=20porta=20tancada=20bloqueja=20via=20Map?= =?UTF-8?q?::collides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/concrete/door.cpp | 7 +++++++ source/game/entities/player.cpp | 20 ++++++++++++++++++-- source/game/map/map.cpp | 12 ++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp index 5e0be92..c2de2a4 100644 --- a/source/game/entities/concrete/door.cpp +++ b/source/game/entities/concrete/door.cpp @@ -6,6 +6,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" +#include "game/map/map.hpp" namespace { @@ -80,6 +81,12 @@ void Door::open() { if (state_ != State::CLOSED) { return; } state_ = State::OPENING; setAnimation(anim_opening_); + // Marquem el passadís com a transitable: a partir d'aquest tic + // `Map::collides` deixa de bloquejar el rect del passadís. La + // transició d'animació OPENING → OPEN encara dura el seu temps, + // però la geometria ja és permeable — coherent amb el feel de + // "la porta s'obri i el jugador pot avançar al moment". + Map::door_open = true; // Edge case: si `opening` té ≤1 frame i és loop=true, mai es // completarà. Per força, forcem loop=false del frame únic aquí. Els // placeholders defineixen `loop: false` explícitament al YAML, així diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index fc97b5c..7577fb6 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -987,11 +987,27 @@ void Player::clampToPlayfield() { const auto MIN_X = static_cast(map_.playfield_x - collision_x_offset); const auto MAX_X = static_cast( map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size); - const auto MIN_Y = static_cast(map_.playfield_y - collision_y_offset); + auto min_y = static_cast(map_.playfield_y - collision_y_offset); const auto MAX_Y = static_cast( map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size); + + // Passadís de sortida: si la base box queda enterament dins del rang + // X del passadís de la porta, deixem que pugi al strip del mur trasser + // (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al + // playfield. La regla "enterament dins" exigeix alinear-se al passadís + // abans de pujar — la base box (12 px) hi cap amb 4 px de marge. + if (map_.door_corridor_w > 0) { + const float BASE_X = getPosX() + static_cast(collision_x_offset); + const float BASE_RIGHT = BASE_X + static_cast(collision_size); + const auto CORRIDOR_LEFT = static_cast(map_.door_corridor_x); + const auto CORRIDOR_RIGHT = static_cast(map_.door_corridor_x + map_.door_corridor_w); + if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) { + min_y = static_cast(map_.door_corridor_y - collision_y_offset); + } + } + setPosX(std::clamp(getPosX(), MIN_X, MAX_X)); - setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y)); + setPosY(std::clamp(getPosY(), min_y, MAX_Y)); } auto Player::getBaseBox() const -> SDL_FRect { diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index f199505..b55912d 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -114,6 +114,18 @@ namespace Map { if (overlapStrict(base_box, getSarcRect(col, row))) { return true; } } } + // Porta tancada: el passadís de sortida actua com a paret. Quan la + // porta passa a OPEN, l'entitat Door posa `door_open = true` i + // aquesta clàusula deixa de bloquejar — el jugador pot creuar. + if (!door_open && door_corridor_w > 0 && door_corridor_h > 0) { + const SDL_FRect CORRIDOR{ + .x = static_cast(door_corridor_x), + .y = static_cast(door_corridor_y), + .w = static_cast(door_corridor_w), + .h = static_cast(door_corridor_h), + }; + if (overlapStrict(base_box, CORRIDOR)) { return true; } + } return false; } From 5c5cd1d5da635e32266498cc11d40724534ae5d0 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:20:13 +0200 Subject: [PATCH 10/18] feat(player): estat EXITED i porta oberta despawna jugadors via player_exited --- source/game/entities/concrete/door.cpp | 15 ++++++++---- source/game/entities/concrete/door.hpp | 12 ++++++---- source/game/entities/player.cpp | 33 ++++++++++++++++++++++++++ source/game/entities/player.hpp | 29 ++++++++++++++++++++++ tests/test_entities_smoke.cpp | 26 ++++++++++---------- 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp index c2de2a4..d1f9c43 100644 --- a/source/game/entities/concrete/door.cpp +++ b/source/game/entities/concrete/door.cpp @@ -66,15 +66,22 @@ void Door::update(float delta_time) { } void Door::onPlayerContact(Player* player) { - if (state_ != State::OPEN || level_complete_emitted_) { return; } + // Només la porta OPEN despawna jugadors. CLOSED/OPENING ignoren el + // contacte (i a més, en CLOSED, el `Map::collides` ja bloqueja el + // moviment, així que el contacte no es dóna en condicions normals). + if (state_ != State::OPEN || player == nullptr || player->isExited()) { return; } + // Marquem el jugador com a EXITED i publiquem l'event d'agregació. + // El check `isExited` de la guarda anterior garanteix que només + // emetem l'event en la primera transició — `EntityManager` compta + // emissions per decidir quan publicar `level_complete`. + player->exit(); if (bus_ != nullptr) { bus_->publish(Event{ - .name = "level_complete", + .name = "player_exited", .payload = std::monostate{}, - .player_id = (player != nullptr) ? player->getId() : -1, + .player_id = player->getId(), }); } - level_complete_emitted_ = true; } void Door::open() { diff --git a/source/game/entities/concrete/door.hpp b/source/game/entities/concrete/door.hpp index f3fa9f2..99cfd1d 100644 --- a/source/game/entities/concrete/door.hpp +++ b/source/game/entities/concrete/door.hpp @@ -7,11 +7,14 @@ #include "game/entities/interactable.hpp" // Porta de sortida del nivell. Tres estats: -// CLOSED → inicial; bloqueja el pas. +// CLOSED → inicial; el passadís de Map bloqueja el pas via `Map::collides`. // OPENING → animació d'obertura en curs. Quan el component acaba → -// transita a OPEN. -// OPEN → accessible. Al contacte amb el jugador publica -// `level_complete` al bus (si n'hi ha). +// transita a OPEN. El passadís ja és transitable (s'allibera +// a `open()` amb `Map::door_open = true`). +// OPEN → accessible. Al contacte amb un jugador, crida `Player::exit()` +// — el jugador queda despawnat i emet `player_exited` al bus. +// L'agregació "tots els jugadors han eixit → `level_complete`" +// viu a EntityManager. // // El que decideix quan s'obri és extern (`EntityManager` comptant sarcs // oberts) via `open()`. L'animació `opening` es fa servir com a @@ -38,7 +41,6 @@ class Door : public Interactable { void setAnimation(const AnimationComponent& anim); State state_{State::CLOSED}; - bool level_complete_emitted_{false}; // Les 3 animacions precalculades al ctor des del YAML. L'activa // s'assigna a `anim_` (heretat d'Entity) segons l'estat. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 7577fb6..06d891b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -328,6 +328,10 @@ void Player::update(float delta_time) { } auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { + // EXITED: jugador despawnat. No xoca amb res ni res xoca amb ell. + // També salta jugadors EXITED del check actor↔actor més avall via + // el filtre de `isExited` (vegeu el predicat any_of). + if (state_ == State::EXITED) { return false; } if (map_.collides(box)) { return true; } if (all_players_ == nullptr) { return false; } // Per al contacte actor-actor usem la hit-box petita a banda i banda: @@ -353,6 +357,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { const float MY_Z = jump_z_; return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } + if (p->isExited()) { return false; } if (MY_HURT || p->isHurt()) { return false; } if (MY_AIR != p->isAirborne()) { return false; } // Aquí els dos estats coincideixen. Si tots dos volen, el filtre @@ -398,7 +403,15 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot } } +// Pipeline d'estats i input separats; extracció a helpers empitjoraria la +// llegibilitat. La complexitat puja 1 per la guarda EXITED. +// NOLINTNEXTLINE(readability-function-cognitive-complexity) void Player::tick(float delta_time) { + // EXITED: jugador despawnat, no fa res. Cap input, cap física, cap + // bus event. Continua sent al vector de jugadors per simplicitat + // (no s'esborra) però queda completament inert fins al respawn. + if (state_ == State::EXITED) { return; } + // Reset per frame: el resolver del GameScene el tornarà a posar a // true si aquest frame hi ha push actiu. Així quan el push acaba // (un deixa d'empentar, l'altre comença a caminar), l'animació torna @@ -740,6 +753,9 @@ void Player::resolveLandingOverlap() { } void Player::applyMovement(float delta_time) { + // EXITED: cap moviment ni col·lisió. Idèntic al gating de `tick`. + if (state_ == State::EXITED) { return; } + // Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_. // El facing i intended_v* es mantenen del moment del despegue (no es // recalculen). Si toquem el sòl aquest frame, transicionem a @@ -859,6 +875,10 @@ void Player::publishTileEnteredIfChanged() { } void Player::render() { + // EXITED: invisible. La crida al render base es salta sencera; el + // jugador no apareix al z-sort tot i seguir vivint al vector. + if (state_ == State::EXITED) { return; } + // Mentre el jugador està enlaire, el sprite es desplaça visualment // cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera // de render) abans de cridar el render base, i la restaurem després @@ -1010,6 +1030,19 @@ void Player::clampToPlayfield() { setPosY(std::clamp(getPosY(), min_y, MAX_Y)); } +void Player::respawn(float x, float y) { + state_ = State::GROUNDED; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + being_pushed_ = false; + jump_z_ = 0.0F; + jump_vz_ = 0.0F; + landing_stun_remaining_ms_ = 0.0F; + turning_remaining_ms_ = 0.0F; + setPosX(x); + setPosY(y); +} + auto Player::getBaseBox() const -> SDL_FRect { return { .x = getPosX() + static_cast(collision_x_offset), diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index b5ebc55..a36b341 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -44,6 +44,12 @@ class Player : public AnimatedSprite { AIRBORNE, LANDING_STUN, HURT, + // Jugador que ha eixit per la porta de la sala. Despawn lògic: + // no es renderitza, no processa input, no es mou, no col·lisiona + // amb res ni rep contacte d'entitats. Idempotent: les crides + // posteriors a `exit()` són no-op. El reset el porta `respawn()` + // (cridada per GameScene en transició OUTRO → INTRO). + EXITED, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -177,12 +183,35 @@ class Player : public AnimatedSprite { [[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; } [[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; } + [[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; } // Tangible per a contactes jugador↔jugador: només els estats a terra // amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen). + // EXITED tampoc — el jugador ha eixit lògicament i ja no és al món. [[nodiscard]] auto isPvpTangible() const -> bool { return state_ == State::GROUNDED || state_ == State::LANDING_STUN; } + // Despawn lògic: el jugador acaba de creuar la porta de sortida. + // Idempotent — només la primera crida fa la transició; les + // posteriors són no-op. La publicació del `player_exited` al bus + // la fa el caller (típicament `Door::onPlayerContact`) per + // mantenir aquesta capçalera lleugera (sense incloure event_bus). + // Definit inline perquè els consumidors de baix nivell (test + // d'entitats) puguen enllaçar Door sense arrossegar player.cpp. + void exit() { + if (state_ == State::EXITED) { return; } + state_ = State::EXITED; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + being_pushed_ = false; + } + + // Reset complet a l'estat inicial: posició a (x, y), velocitats a 0, + // estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat + // per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent + // (es pot cridar des de qualsevol estat). + void respawn(float x, float y); + // Alçada actual del salt (píxels visuals per damunt del sòl). // 0 quan està a terra. L'usa el render i el sistema de col·lisions // per altura. diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 8541ea5..abaeac3 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -7,7 +7,9 @@ // T4 sarcòfag — perímetre computat correctament; notifyTilePainted // passa a OPENING quan es visiten tots els tiles. // T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta -// s'obri sola; contactar la porta OPEN emet `level_complete`. +// s'obri sola. El contacte de la porta amb un jugador +// nul·l és no-op segur (la porta ja no emet `level_complete` +// directament; ho fa EntityManager comptant `player_exited`). // T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1. // T7 plantilles — carrega els 7 YAML d'entitat sense excepcions. // T8 decoration — Torch no col·lisiona ni és interactable. @@ -173,12 +175,6 @@ namespace { manager.add(std::move(door)); manager.setDoor(door_ptr); - // Subscriptor local per capturar `level_complete`. - bool level_complete_fired = false; - manager.getBus().subscribe("level_complete", [&](const Event&) { - level_complete_fired = true; - }); - // Forcem l'obertura dels dos sarcs visitant els seus perímetres. auto forceOpen = [](Sarcophagus* s) { const int COL0 = s->getTilePos().col; @@ -206,12 +202,16 @@ namespace { REQUIRE(manager.openedSarcophagusCount() == 2); REQUIRE(door_ptr->getState() == Door::State::OPEN); - // Contacte amb la porta oberta → level_complete. - door_ptr->onPlayerContact(nullptr); - REQUIRE(level_complete_fired); - - // Un segon contacte NO torna a disparar (idempotent). - level_complete_fired = false; + // Contacte sense jugador (nullptr): no-op segur. La porta ja no + // emet `level_complete` directament — ara és EntityManager qui + // l'emet quan tots els jugadors han eixit. La cobertura + // d'aquest agregat viu al test específic d'EntityManager + // (test_game_fsm) perquè requereix instanciar Player, fora + // d'abast d'aquest smoke purament d'entitats. + bool level_complete_fired = false; + manager.getBus().subscribe("level_complete", [&](const Event&) { + level_complete_fired = true; + }); door_ptr->onPlayerContact(nullptr); REQUIRE(!level_complete_fired); } From 28e55a0615bf9ba490b709ad90467c003bc65b4c Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:22:42 +0200 Subject: [PATCH 11/18] feat(entity-manager): comptador player_exited agrega a level_complete --- source/game/entities/entity_manager.cpp | 26 +++++++++++++++++++++++++ source/game/entities/entity_manager.hpp | 15 ++++++++++++++ source/game/scenes/game_scene.cpp | 6 ++++++ 3 files changed, 47 insertions(+) diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index 01125fd..8cf2371 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -32,11 +32,37 @@ EntityManager::EntityManager() { const auto* tile = std::get_if(&ev.payload); if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); } }); + + // Subscripció a `player_exited` (publicat per Door en travessar la + // porta oberta amb un jugador): comptem una vegada per jugador i, + // quan tots els jugadors actius han eixit, emetem `level_complete` + // — l'event d'agregat que GameScene escolta per disparar la + // transició FSM a ROOM_OUTRO. La idempotència de Door (check + // `isExited`) garanteix que cada jugador suma com a màxim un cop. + exit_sub_id_ = bus_.subscribe("player_exited", [this](const Event& /*ev*/) { + ++exited_players_; + if (level_complete_emitted_) { return; } + if (expected_players_ > 0 && exited_players_ >= expected_players_) { + level_complete_emitted_ = true; + bus_.publish(Event{ + .name = "level_complete", + .payload = std::monostate{}, + .player_id = -1, + }); + } + }); } EntityManager::~EntityManager() { bus_.unsubscribe(sarc_sub_id_); bus_.unsubscribe(tile_sub_id_); + bus_.unsubscribe(exit_sub_id_); +} + +void EntityManager::setExpectedPlayerCount(int n) { + expected_players_ = n; + exited_players_ = 0; + level_complete_emitted_ = false; } auto EntityManager::add(std::unique_ptr entity) -> Entity* { diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 000d6fc..da9192a 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -90,10 +90,17 @@ class EntityManager { // `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`). void loadFromLevel(const std::string& level_yaml_path); + // Fixa quants jugadors actius es consideren al recompte d'EXITED. + // Cridat per GameScene un cop instanciats els jugadors. Si manca, + // `expected_players_ = 0` i mai s'emet `level_complete` automàticament. + // Cridar-lo de nou al respawn (futur reload) reseteja el comptador. + void setExpectedPlayerCount(int n); + [[nodiscard]] auto getBus() -> EventBus& { return bus_; } [[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); } [[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; } [[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; } + [[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; } private: EventBus bus_; @@ -101,6 +108,14 @@ class EntityManager { Door* door_{nullptr}; std::size_t total_sarcs_{0}; std::size_t opened_sarcs_{0}; + // Recompte d'`player_exited`: incrementat a la subscripció al bus, + // resetejat per `setExpectedPlayerCount`. Quan `exited_players_` arriba + // a `expected_players_` (i tots dos > 0), publica `level_complete` + // amb `player_id = -1` (event d'agregat, no atribuible a un jugador). + int expected_players_{0}; + int exited_players_{0}; + bool level_complete_emitted_{false}; EventBus::SubId sarc_sub_id_{0}; EventBus::SubId tile_sub_id_{0}; + EventBus::SubId exit_sub_id_{0}; }; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index cc9f4f4..179dee4 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -367,6 +367,12 @@ GameScene::GameScene(Config config) players_view_.reserve(players_.size()); std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); }); for (auto& p : players_) { p->setOtherPlayers(&players_view_); } + + // Comptador d'EXITED a EntityManager: publica `level_complete` quan + // tots els jugadors actius han travessat la porta. Cap setExpectedPlayerCount + // ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí + // perquè ja sabem el nombre real d'instàncies. + entity_manager_.setExpectedPlayerCount(static_cast(players_.size())); // El snap també empeny lateralment si l'altre jugador ocupa el carril: // passem el mateix factor perquè vaja al mateix ritme que el resolver. for (auto& p : players_) { p->setPushFactor(push_factor_); } From 59cd9b524c587b6a406a6746a954ca4c0036148b Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:26:07 +0200 Subject: [PATCH 12/18] feat(entity-manager): reload del nivell + PaintLayer::clear --- source/game/entities/entity_manager.hpp | 10 ++++++++++ .../entities/entity_manager_loadlevel.cpp | 19 +++++++++++++++++++ source/game/map/paint_layer.cpp | 7 +++++++ source/game/map/paint_layer.hpp | 7 +++++++ 4 files changed, 43 insertions(+) diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index da9192a..30453d6 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -90,6 +90,16 @@ class EntityManager { // `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`). void loadFromLevel(const std::string& level_yaml_path); + // Reset complet del nivell: destrueix totes les entitats, reseteja + // els comptadors interns (sarcòfags oberts, total, exited, flag de + // level_complete) i torna a cridar `loadFromLevel` per regenerar les + // entitats des del YAML. El bus i les seues subscripcions + // sobreviuen — això inclou les subscripcions externes (paint_layer_, + // hud_, players, GameScene). Cridat des de GameScene a la transició + // ROOM_OUTRO → ROOM_INTRO. `setExpectedPlayerCount` cal cridar-lo de + // nou després si vols agregació de level_complete al nou cicle. + void reload(const std::string& level_yaml_path); + // Fixa quants jugadors actius es consideren al recompte d'EXITED. // Cridat per GameScene un cop instanciats els jugadors. Si manca, // `expected_players_ = 0` i mai s'emet `level_complete` automàticament. diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index ee0f0ef..b73080f 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -355,6 +355,25 @@ namespace { } } // namespace +void EntityManager::reload(const std::string& level_yaml_path) { + // Destrucció ordenada: les entitats moren aquí; les seues + // subscripcions implícites desapareixen amb la seua vida (cap d'elles + // se subscriu directament al bus — només publiquen). El bus i les + // subscripcions externes (PaintLayer, HUD, GameScene) no es toquen. + entities_.clear(); + door_ = nullptr; + total_sarcs_ = 0; + opened_sarcs_ = 0; + // No reset de `expected_players_`: GameScene el tornarà a fixar + // (amb el mateix valor o un de nou) després del reload, com a part + // del respawn dels jugadors. Reseteig dels comptadors d'EXITED i + // del flag d'emissió perquè el cicle següent pugui reagregar net. + exited_players_ = 0; + level_complete_emitted_ = false; + + loadFromLevel(level_yaml_path); +} + void EntityManager::loadFromLevel(const std::string& level_yaml_path) { const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); int loaded = 0; diff --git a/source/game/map/paint_layer.cpp b/source/game/map/paint_layer.cpp index 297b629..5d79b20 100644 --- a/source/game/map/paint_layer.cpp +++ b/source/game/map/paint_layer.cpp @@ -82,6 +82,13 @@ auto PaintLayer::isPainted(int col, int row) const -> bool { return painted_[static_cast(row)][static_cast(col)] >= 0; } +void PaintLayer::clear() { + // Mateixa geometria que al ctor: matriu de tiles a -1 (no pintat) i + // buffer ARGB a zero (alpha=0 = transparent → render no escriu res). + painted_.assign(static_cast(rows_), std::vector(static_cast(cols_), -1)); + std::ranges::fill(pixels_, 0U); +} + auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool { if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; } if (tile_index < 0) { return false; } diff --git a/source/game/map/paint_layer.hpp b/source/game/map/paint_layer.hpp index d67c09e..6885823 100644 --- a/source/game/map/paint_layer.hpp +++ b/source/game/map/paint_layer.hpp @@ -48,6 +48,13 @@ class PaintLayer { [[nodiscard]] auto isPainted(int col, int row) const -> bool; + // Buida el pintat acumulat. Marca tots els tiles com a no pintats i + // reseteja el buffer ARGB a alpha=0 (transparent). Cridat per + // GameScene a la transició ROOM_OUTRO → ROOM_INTRO. La subscripció + // al bus es manté — el PaintLayer continuarà reaccionant a + // `on_tile_entered` un cop els jugadors hagin reaparegut. + void clear(); + private: void blitPaintTile(int col, int row, int tile_index); From 811c6c482df75b6a7756c087ab285fa40b3fb916 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:30:31 +0200 Subject: [PATCH 13/18] feat(game-scene): spawn lligat a door_col i respawn a OUTRO->INTRO --- source/game/scenes/game_scene.cpp | 86 ++++++++++++++++++++++++------- source/game/scenes/game_scene.hpp | 19 +++++++ 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 179dee4..7204e11 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -310,23 +310,12 @@ GameScene::GameScene(Config config) .right = Defaults::KeysGameP2::RIGHT, .jump = Defaults::KeysGameP2::JUMP, }; - // Spawn centrat al passadís exterior superior: la caixa de col·lisió - // queda dins del passadís vertical del mig (entre columnes 1 i 2) i - // centrada en Y al passadís exterior superior. Així no naix dins de - // cap sarcòfag. - constexpr auto CORRIDOR_CENTER_X = static_cast(GameCanvas::CENTER_X); - const auto CORRIDOR_CENTER_Y = - static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); - const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); - const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); - - // En mode BOTH separem els jugadors un tile a banda i banda del - // centre perquè no naixen superposats. En mode single-player - // (P1 o P2 aïllats per a proves de dev) l'únic jugador arrenca al - // centre exacte. + // Spawn lligat a la porta: tots els jugadors apareixen davant del + // passadís de sortida. `computeSpawn` deriva les coordenades de + // `Map::door_col` perquè canviar la columna de la porta al YAML + // moga automàticament l'spawn — no hi ha cap valor hardcoded. + const auto SPAWN = computeSpawn(); const bool BOTH = (USE_P1 && USE_P2); - const auto P1_SPAWN_X = BOTH ? SPAWN_X - static_cast(Map::tile_size) : SPAWN_X; - const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast(Map::tile_size) : SPAWN_X; // Índex de tile amb què pinta cada jugador — llegit del YAML del mapa // (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no @@ -338,11 +327,13 @@ GameScene::GameScene(Config config) constexpr int ID_P2 = 1; if (USE_P1) { const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); - players_.emplace_back(std::make_unique(anim_p1, ID_P1, P1_SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); + const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast(Map::tile_size); + players_.emplace_back(std::make_unique(anim_p1, ID_P1, X, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); } if (USE_P2) { const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); - players_.emplace_back(std::make_unique(anim_p2, ID_P2, P2_SPAWN_X, SPAWN_Y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); + const float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast(Map::tile_size); + players_.emplace_back(std::make_unique(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); } // PaintLayer: font única del pintat del nivell. Es crea després de @@ -417,11 +408,70 @@ GameScene::GameScene(Config config) GameScene::~GameScene() = default; +auto GameScene::computeSpawn() -> PlayerSpawn { + // Centre de la columna del passadís en píxels canvas. La caixa de + // col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2 + // px de marge a banda i banda; alinear el sprite al centre de la + // columna garanteix que la base del jugador i la del passadís + // coincideixin verticalment. + const float DOOR_CENTER_X = static_cast((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast(Map::tile_size) / 2.0F); + const float CENTER_TL_X = DOOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); + + // Y: centre del corredor exterior superior (mateix valor que abans; + // el corredor és simètric respecte a `playfield_y`). + const float CORRIDOR_CENTER_Y = static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); + const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); + + // Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre. + // Els callsites individuals decideixen si usar aquests valors o si + // estem en mode single-player (un sol jugador → centrat exactament). + return { + .p1_x = CENTER_TL_X - static_cast(Map::tile_size), + .p2_x = CENTER_TL_X + static_cast(Map::tile_size), + .y = SPAWN_Y, + }; +} + +void GameScene::resetRoom() { + // Recarregar les entitats des del YAML del mapa: la porta torna a + // CLOSED (amb l'open=false a Map al loadFrom), els sarcs tornen a + // CLOSED amb visited buit, momies i altres actors a la posició inicial. + Map::loadFrom(config_.map_yaml); + entity_manager_.reload(config_.map_yaml); + entity_manager_.setExpectedPlayerCount(static_cast(players_.size())); + if (paint_layer_) { paint_layer_->clear(); } + + // Respawn dels jugadors. Recalcular spawn per si door_col canvia + // (avui el mapa no canvia entre cicles, però la lògica ja queda + // preparada per a l'itinerari futur). + const auto SPAWN = computeSpawn(); + const bool BOTH = (players_.size() >= 2); + // Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x + + // tile_size, perquè SPAWN.p1_x és el centre menys un tile). + const float SINGLE_X = SPAWN.p1_x + static_cast(Map::tile_size); + for (std::size_t i = 0; i < players_.size(); ++i) { + float x = SINGLE_X; + if (BOTH) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; } + players_[i]->respawn(x, SPAWN.y); + } + + std::cout << "[ROOM] reset: entities reloaded, paint cleared, " + << players_.size() << " player(s) respawned\n"; +} + void GameScene::setState(RoomFsm::State new_state) { if (new_state == state_) { return; } std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n'; + const auto PREV = state_; state_ = new_state; transition_ = {}; + // En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de + // "torna a la mateixa sala"), reseteja completament l'estat del + // nivell. Si la transició és la inicial (PREV també és ROOM_INTRO, + // imposible amb el guard de dalt) o ve d'un altre estat, no fa res. + if (new_state == RoomFsm::State::ROOM_INTRO && PREV == RoomFsm::State::ROOM_OUTRO) { + resetRoom(); + } } void GameScene::updateRoomTransition(int delta_ms) { diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 1f85996..b8f1149 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -168,6 +168,25 @@ class GameScene : public Scene { void renderRoomFade(); void renderRoomText(); + // Posició inicial dels jugadors derivada de `Map::door_col`. Tots + // els jugadors comparteixen Y (centre del corredor exterior superior); + // X depèn del nombre actiu: amb un jugador queda centrat sota la + // porta; amb dos, separats ±tile_size respecte al centre. Estructura + // exposada perquè els tests puguen verificar-la sense entrar al ctor + // sencer. + struct PlayerSpawn { + float p1_x{0.0F}; + float p2_x{0.0F}; + float y{0.0F}; + }; + [[nodiscard]] static auto computeSpawn() -> PlayerSpawn; + + // Reset complet de la sala: reload de l'EntityManager (sarcs/porta/ + // momies des de zero), neteja del PaintLayer i respawn dels jugadors + // a la posició calculada per `computeSpawn`. Cridat des de + // `setState(ROOM_INTRO)` quan la transició anterior era OUTRO. + void resetRoom(); + #ifdef _DEBUG // Editor modal del tilemap. Mútuament excloent amb la pausa: si la // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i From 178ce486c22bde877170bd30be5b77fc14853294 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:32:20 +0200 Subject: [PATCH 14/18] test(entity-manager): smoke del comptador player_exited i level_complete agregat --- tests/test_entities_smoke.cpp | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index abaeac3..ff6e938 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -22,6 +22,10 @@ // sprite sencer (pixel_rect). // T13 mummy step — camina en línia recta pel playfield i gira en topar // un mur (insidePlayfield=false) o sarc (Map::collides). +// T14 exited aggregator — EntityManager compta `player_exited` i emet +// `level_complete` quan iguala `expected_players`. Verifica +// idempotència (3r event no re-emet) i reset del comptador +// via `setExpectedPlayerCount` (cicle següent torna a emetre). // // Execució (des de la ruta del repo): // make build && cd build && ctest --output-on-failure @@ -449,6 +453,46 @@ namespace { REQUIRE(torch.getAnimation()->currentFrame() == 2); } + void testExitedAggregator() { + std::cout << "[T14] EntityManager: player_exited → level_complete\n"; + EntityManager manager; + + // Comptador inicialitzat per a 2 jugadors. Sense aquesta crida, + // l'agregador no faria res (expected_players_ = 0). + manager.setExpectedPlayerCount(2); + + int level_complete_count = 0; + manager.getBus().subscribe("level_complete", [&](const Event& ev) { + ++level_complete_count; + // Event d'agregat: no atribuïble a un jugador concret. + REQUIRE(ev.player_id == -1); + }); + + // Primer player_exited: comptador a 1 < 2, no emet encara. + manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0}); + REQUIRE(manager.exitedPlayerCount() == 1); + REQUIRE(level_complete_count == 0); + + // Segon player_exited: comptador a 2 == expected, emet una vegada. + manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1}); + REQUIRE(manager.exitedPlayerCount() == 2); + REQUIRE(level_complete_count == 1); + + // Un tercer player_exited (no hauria de passar amb 2 jugadors, + // però defensa contra publicacions extra): no torna a emetre. + manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0}); + REQUIRE(level_complete_count == 1); + + // setExpectedPlayerCount torna a obrir el cicle (això simularia + // el que fa GameScene després d'un reload de sala). El comptador + // d'EXITED es reinicia a 0 i el flag d'emissió també. + manager.setExpectedPlayerCount(2); + REQUIRE(manager.exitedPlayerCount() == 0); + manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0}); + manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1}); + REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més + } + } // namespace auto main() -> int { @@ -467,6 +511,7 @@ auto main() -> int { testCollisionBox(); testMummyStep(); testTorchAnimation(); + testExitedAggregator(); if (g_failures == 0) { std::cout << "ALL OK\n"; From cd5bb9422308606e58af2a53d9ea099ff9a18bbe Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:41:05 +0200 Subject: [PATCH 15/18] fix(player): EXITED no participa al resolver ni a checkPlayerCollisions --- source/game/entities/entity_manager_loadlevel.cpp | 2 +- source/game/entities/player.hpp | 7 +++++++ source/game/scenes/game_scene.cpp | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index b73080f..fba6b3e 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -65,7 +65,7 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { - if (p == nullptr) { continue; } + if (p == nullptr || p->isExited()) { continue; } // Filtre per altura: si el jugador està en l'aire i està per // damunt de l'alçada del cos de l'entitat, no hi ha contacte. // És el mecanisme del salt per esquivar momies, roques, etc. diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index a36b341..c8c3bfb 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -203,6 +203,13 @@ class Player : public AnimatedSprite { state_ = State::EXITED; intended_vx_ = 0.0F; intended_vy_ = 0.0F; + // Important: també resetegem `input_v*` perquè el resolver + // jugador↔jugador (`resolvePlayerVsPlayer`) consulta + // `getInputVX/Y` per decidir si un jugador "es mou". Un EXITED + // amb input fantasma del moment d'entrar a la porta dispararia + // la branca head-on i bloquejaria l'altre jugador en sec. + input_vx_ = 0.0F; + input_vy_ = 0.0F; being_pushed_ = false; } diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 7204e11..7a52f92 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -113,6 +113,12 @@ namespace { // surt projectat sense control i els dos es travessen. Coincideix // amb la regla d'`Player::collidesWithWorld`. if (a->isHurt() || b->isHurt()) { return; } + // EXITED: el jugador despawnat no és tangible. Si entrés al + // resolver, la seua presència física frenaria l'altre (per + // overlap predictiu) tot i que `applyMovement` és no-op. Sortir + // d'aquí garanteix que el segon jugador pot creuar la porta + // sense interferència del primer ja eixit. + if (a->isExited() || b->isExited()) { return; } // Aire ↔ tierra: el saltador es travessa net del caminant. Sense // resolver, sense head-on, sense push. Els dos continuen amb la From d054d1b246734ad36f69a665d361d86471e1e5cd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:47:13 +0200 Subject: [PATCH 16/18] fix(player): respawn reseteja facing a DOWN i input/cardinal pendents --- source/game/entities/player.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 06d891b..fdc2481 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -1034,11 +1034,22 @@ void Player::respawn(float x, float y) { state_ = State::GROUNDED; intended_vx_ = 0.0F; intended_vy_ = 0.0F; + input_vx_ = 0.0F; + input_vy_ = 0.0F; being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; + pending_cardinal_ = Cardinal::NONE; + intermediate_cardinal_ = Cardinal::NONE; + last_input_cardinal_ = Cardinal::NONE; + // Reseteig del facing al d'spawn original (mirant cap a la càmera): + // sense això, un EXITED que entrava per la porta amb facing UP + // re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor. + facing_ = Facing::DOWN; + setFlip(SDL_FLIP_NONE); + setCurrentAnimation("stand_down"); setPosX(x); setPosY(y); } From c0214b233b9fc9a1790a2a9a15357f15610a1bf2 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:47:41 +0200 Subject: [PATCH 17/18] =?UTF-8?q?fix(game-scene):=20text=20d'INTRO/OUTRO?= =?UTF-8?q?=20surt=20cap=20a=20la=20dreta=20despr=C3=A9s=20del=20hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 7a52f92..04dd9df 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -545,7 +545,12 @@ void GameScene::renderRoomText() { constexpr int KERNING = 0; const int W = pause_font_->length(text, KERNING); + // El text entra des de l'esquerra (off-screen amb el seu cantó dret + // a x=0) i, després del hold, surt cap a la dreta (off-screen amb + // el seu cantó esquerre a x=WIDTH). Així el text "travessa" la + // pantalla en una sola direcció en compte de tornar enrere. const int OFF_LEFT = -W; + const int OFF_RIGHT = GameCanvas::WIDTH; int x = GameCanvas::CENTER_X; if (transition_.phase == phase_enter) { @@ -559,7 +564,7 @@ void GameScene::renderRoomText() { ? std::clamp(static_cast(transition_.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; - x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_LEFT, CURVE(T)); + x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T)); } constexpr Uint8 OUTLINE_WHITE = 255; From cd961cf7188f29e88efdfb2e1ae6f8c2ba334156 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 10:51:40 +0200 Subject: [PATCH 18/18] =?UTF-8?q?chore:=20bump=20v0.24.0=20=E2=80=94=20FSM?= =?UTF-8?q?=20de=20sala,=20EXITED=20i=20bucle=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/utils/defines.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 2a6ae39..d52ca21 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; - constexpr const char* VERSION = "0.23.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.24.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {