test(game-scene): extracció RoomFsm i smoke test de la FSM de sala

This commit is contained in:
JailDesigner committed 2026-04-26 09:10:54 +02:00
1 parent 9a2c677153
commit 63bb41425a
6 files changed
+493 -211

No files matched your search

+25
View File
@@ -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()
+14 -152
View File
@@ -3,14 +3,12 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
#include <string_view>
#include <utility>
#include <variant>
#include <vector>
@@ -77,63 +75,6 @@ namespace {
return yaml[key].get_value<float>();
}
// 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<int>();
}
// 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<const char*>(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<std::string>());
}
if (n.contains("exit_curve")) {
cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value<std::string>());
}
}
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<int>(&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<int, 5> 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<int>(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) {
transition_.elapsed_ms -= DURS[transition_.phase];
++transition_.phase;
}
if (transition_.phase >= static_cast<int>(DURS.size())) {
setState(State::PLAYING);
}
return;
}
if (state_ == State::ROOM_OUTRO) {
const std::array<int, 4> 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<int>(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) {
transition_.elapsed_ms -= DURS[transition_.phase];
++transition_.phase;
}
if (transition_.phase >= static_cast<int>(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<int>(static_cast<float>(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<float>(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
+13 -59
View File
@@ -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.
+131
View File
@@ -0,0 +1,131 @@
#include "game/scenes/room_fsm.hpp"
#include <array>
#include <iostream>
#include <string_view>
#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<int>();
}
} // 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<const char*>(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<std::string>());
}
if (n.contains("exit_curve")) {
cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value<std::string>());
}
}
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<int, 5> 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<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::PLAYING; }
return current;
}
// ROOM_OUTRO
const std::array<int, 4> 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<int>(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<int>(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
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <string>
#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
+240
View File
@@ -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 <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#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;
}