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

+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