From 63bb41425a467c2907621673812931524d8325f1 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:10:54 +0200 Subject: [PATCH] =?UTF-8?q?test(game-scene):=20extracci=C3=B3=20RoomFsm=20?= =?UTF-8?q?i=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; +}