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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user