test(game-scene): extracció RoomFsm i smoke test de la FSM de sala
This commit is contained in:
1 parent
9a2c677153
commit
63bb41425a
6 files changed
+493
-211
No files matched your search
@@ -95,6 +95,7 @@ set(APP_SOURCES
|
|||||||
source/game/scenes/title_scene.cpp
|
source/game/scenes/title_scene.cpp
|
||||||
source/game/scenes/intro_scene.cpp
|
source/game/scenes/intro_scene.cpp
|
||||||
source/game/scenes/game_scene.cpp
|
source/game/scenes/game_scene.cpp
|
||||||
|
source/game/scenes/room_fsm.cpp
|
||||||
source/game/entities/player.cpp
|
source/game/entities/player.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
source/game/map/tilemap.cpp
|
source/game/map/tilemap.cpp
|
||||||
@@ -374,4 +375,28 @@ if(BUILD_TESTS)
|
|||||||
add_test(NAME tilemap_writer
|
add_test(NAME tilemap_writer
|
||||||
COMMAND test_tilemap_writer
|
COMMAND test_tilemap_writer
|
||||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
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()
|
endif()
|
||||||
@@ -3,14 +3,12 @@
|
|||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
#include <string_view>
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <variant>
|
#include <variant>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -77,63 +75,6 @@ namespace {
|
|||||||
return yaml[key].get_value<float>();
|
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
|
// Comprova si `a` i `b` se solapen amb una tolerància positiva al
|
||||||
// contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja
|
// contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja
|
||||||
// ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa
|
// ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa
|
||||||
@@ -329,7 +270,7 @@ GameScene::GameScene(Config config)
|
|||||||
// transició ja en marxa.
|
// transició ja en marxa.
|
||||||
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
|
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
|
||||||
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
|
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) {
|
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
|
||||||
const auto* dmg = std::get_if<int>(&ev.payload);
|
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
|
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
||||||
// vegada; canvis requereixen reiniciar el joc.
|
// 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
|
// 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
|
// les transicions són instantànies (sense fades ni text) i el primer
|
||||||
@@ -470,95 +411,16 @@ GameScene::GameScene(Config config)
|
|||||||
|
|
||||||
GameScene::~GameScene() = default;
|
GameScene::~GameScene() = default;
|
||||||
|
|
||||||
namespace {
|
void GameScene::setState(RoomFsm::State new_state) {
|
||||||
// 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; }
|
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;
|
state_ = new_state;
|
||||||
transition_ = {}; // sub-fase i elapsed s'inicialitzen a zero a cada estat.
|
transition_ = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameScene::updateRoomTransition(int delta_ms) {
|
void GameScene::updateRoomTransition(int delta_ms) {
|
||||||
if (state_ == State::PLAYING) { return; }
|
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
|
||||||
|
if (NEXT != state_) { setState(NEXT); }
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameScene::renderRoomFade() {
|
void GameScene::renderRoomFade() {
|
||||||
@@ -569,10 +431,10 @@ void GameScene::renderRoomFade() {
|
|||||||
int dur = 0;
|
int dur = 0;
|
||||||
float t = 0.0F;
|
float t = 0.0F;
|
||||||
bool fade_in = false;
|
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;
|
dur = transition_config_.intro_fade_in_ms;
|
||||||
fade_in = true;
|
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;
|
dur = transition_config_.outro_fade_out_ms;
|
||||||
fade_in = false;
|
fade_in = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -603,13 +465,13 @@ void GameScene::renderRoomText() {
|
|||||||
int phase_exit = 0;
|
int phase_exit = 0;
|
||||||
int dur_enter = 0;
|
int dur_enter = 0;
|
||||||
int dur_exit = 0;
|
int dur_exit = 0;
|
||||||
if (state_ == State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
|
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
|
||||||
text = formatRoomTitle(room_index_);
|
text = RoomFsm::formatRoomTitle(room_index_);
|
||||||
phase_enter = 2;
|
phase_enter = 2;
|
||||||
phase_exit = 4;
|
phase_exit = 4;
|
||||||
dur_enter = transition_config_.intro_text_enter_ms;
|
dur_enter = transition_config_.intro_text_enter_ms;
|
||||||
dur_exit = transition_config_.intro_text_exit_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");
|
text = Locale::get().text("game.room_complete");
|
||||||
phase_enter = 0;
|
phase_enter = 0;
|
||||||
phase_exit = 2;
|
phase_exit = 2;
|
||||||
@@ -796,7 +658,7 @@ void GameScene::update(int delta_ms) {
|
|||||||
constexpr bool EDITOR_FROZEN = false;
|
constexpr bool EDITOR_FROZEN = false;
|
||||||
#endif
|
#endif
|
||||||
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
|
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) {
|
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
|
||||||
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
|
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.
|
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
|
||||||
// Les entitats sí que es renderitzen perquè el disseny demana que
|
// Les entitats sí que es renderitzen perquè el disseny demana que
|
||||||
// el món continui animant-se durant el fade i el text.
|
// 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) {
|
if (RENDER_PLAYERS) {
|
||||||
// Passada de sombres dels jugadors saltant. Va per davant del
|
// Passada de sombres dels jugadors saltant. Va per davant del
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "game/entities/entity_manager.hpp"
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/map/paint_layer.hpp"
|
#include "game/map/paint_layer.hpp"
|
||||||
#include "game/map/tilemap.hpp"
|
#include "game/map/tilemap.hpp"
|
||||||
|
#include "game/scenes/room_fsm.hpp"
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
#include "game/editor/editor.hpp"
|
#include "game/editor/editor.hpp"
|
||||||
@@ -38,39 +39,6 @@ class GameScene : public Scene {
|
|||||||
|
|
||||||
[[nodiscard]] static auto defaultConfig() -> Config;
|
[[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());
|
explicit GameScene(Config config = defaultConfig());
|
||||||
~GameScene() override;
|
~GameScene() override;
|
||||||
|
|
||||||
@@ -78,16 +46,15 @@ class GameScene : public Scene {
|
|||||||
void render() override;
|
void render() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Punt centralitzat per canviar d'estat. Imprimeix la transició i
|
// Punt centralitzat per canviar d'estat de sala. Imprimeix la
|
||||||
// reinicialitza els acumuladors de la transició de sala. Cap estat
|
// transició i reinicialitza els acumuladors de la `RoomFsm::Transition`.
|
||||||
// s'ha de canviar fora d'aquí.
|
// Cap estat s'ha de canviar fora d'aquí.
|
||||||
void setState(State new_state);
|
void setState(RoomFsm::State new_state);
|
||||||
|
|
||||||
// Avança la transició activa (sub-fases, timings). En aquesta fita
|
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
|
||||||
// les transicions són instantànies (sense efectes); les sub-fases
|
// frame amb delta real (no escalat per la pausa) perquè la transició
|
||||||
// amb fades i text arribaran als commits següents. S'executa cada
|
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
|
||||||
// frame amb delta real (no escalat per la pausa) perquè la
|
// crida `setState` (que reseteja la `Transition`).
|
||||||
// transició viu en temps de UI.
|
|
||||||
void updateRoomTransition(int delta_ms);
|
void updateRoomTransition(int delta_ms);
|
||||||
|
|
||||||
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó
|
// 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
|
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
|
||||||
// de `data/scenes/game.yaml`; valors per defecte si manca el YAML.
|
// 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
|
// 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
|
// perquè cada vegada que entres al joc passes per la presentació de
|
||||||
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
|
// 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
|
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
|
||||||
// reseteja a {0, 0}. La interpretació depèn de l'estat:
|
// reseteja a {0, 0}.
|
||||||
// ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit
|
RoomFsm::Transition transition_{};
|
||||||
// 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.
|
// Í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
|
// 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.
|
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
|
||||||
int room_index_{1};
|
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.
|
// 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 —
|
// Cridats des de `render()` entre el bloc d'actors i el HUD —
|
||||||
// així marcador, notificacions i service menu queden per damunt.
|
// així marcador, notificacions i service menu queden per damunt.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user