feat(level-manager): HUD i text d'INTRO segueixen el LevelManager

HUD: la GameScene crida hud_->setPyramidRoom amb els valors del
LevelManager al ctor i a cada loadRoom — el marcador deixa de
mostrar 1/1 fix i s'actualitza en avançar de sala, en saltar amb
[/] i en arrencar amb starting_level del debug.yaml.

INTRO a dues línies: substitueix la clau game.room_intro per
game.room_intro_pyramid ("Piramide {p}") i game.room_intro_room
("Sala {r}") als locale ca/en. RoomFsm::formatRoomTitle(int)
desapareix i el reemplacen formatIntroPyramidLine(int) +
formatIntroRoomLine(int). renderRoomText es refactoritza per
dibuixar les dues línies (piràmide damunt, sala davall) amb
writeDX independents — comparteixen animació ara mateix però el
disseny ja les deixa preparades per ser desacoblades en el futur.

Drive-by: elimina el membre room_index_ de GameScene; les tres
ubicacions que el sincronitzaven amb el LevelManager (ctor,
advanceToNextRoom, salt debug) ja no fan falta — renderRoomText
i el HUD pregunten directament al singleton, evitant qualsevol
mirror local desincronitzable.

Test: T9/T10 actualitzats per a les dues noves formatadores.
This commit is contained in:
JailDesigner committed 2026-04-26 15:25:22 +02:00
1 parent b18bd4d021
commit e2f6d326aa
7 files changed
+130 -80

No files matched your search

+6 -3
View File
@@ -37,10 +37,13 @@ hud:
room: "Sala"
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
# `room_intro` accepta el placeholder `{n}` que es substitueix amb
# l'índex de sala actual; veure `GameScene::formatRoomTitle`.
# Les dues primeres claus es renderitzen com a línies separades durant
# l'INTRO (preparades per ser animades de manera independent en el
# futur). Accepten els placeholders `{p}` (piràmide) i `{r}` (sala) que
# es substitueixen amb els valors actuals del LevelManager.
game:
room_intro: "Sala {n}"
room_intro_pyramid: "Piramide {p}"
room_intro_room: "Sala {r}"
room_complete: "Fase completada"
menu:
+7 -4
View File
@@ -36,11 +36,14 @@ hud:
pyramid: "Pyramid"
room: "Room"
# Room transition texts (GameScene FSM intro/outro). The `room_intro`
# string accepts a `{n}` placeholder that is replaced with the current
# room index; see `GameScene::formatRoomTitle`.
# Room transition texts (GameScene FSM intro/outro). The two pyramid /
# room strings render as independent lines during INTRO (so they can
# be animated separately in the future). They accept `{p}` (pyramid)
# and `{r}` (room) placeholders, replaced with the current LevelManager
# values.
game:
room_intro: "Room {n}"
room_intro_pyramid: "Pyramid {p}"
room_intro_room: "Room {r}"
room_complete: "Stage complete"
menu:
+74 -48
View File
@@ -239,12 +239,11 @@ auto GameScene::defaultConfig() -> Config {
GameScene::GameScene(Config config)
: config_(std::move(config)),
tilemap_(makeTilemap(config_.map_yaml)) {
// Sincronitzem l'identificador de la sala amb el LevelManager (la
// mateixa font que ha donat el `map_yaml`). El text d'intro encara
// consumeix només `{n}` = número de sala dins de la piràmide; quan
// afegim el placeholder de piràmide al locale, ací també caldrà
// passar-li `currentPyramid()`.
room_index_ = Levels::LevelManager::get().currentRoom();
// El text d'INTRO i el HUD demanaran piràmide/sala directament al
// LevelManager — `GameScene` ja no manté un mirror local. La
// sincronització "una vegada per càrrega de sala" la fa `loadRoom`
// (per al HUD) i `renderRoomText` (per al text d'INTRO, llegint el
// valor a cada frame de la transició).
// Convenció: el Resource::Cache indexa per basename del fitxer, perquè
// la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile).
@@ -415,6 +414,13 @@ GameScene::GameScene(Config config)
// només P1). Mentre els events del bus no porten `player_id`, els hits
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
if (USE_P2) { hud_->activatePlayer(2); }
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
// el LevelManager (després de cada `loadRoom` també es refresca; ací
// és el primer ajust per a la sala d'arrencada).
{
const auto& mgr = Levels::LevelManager::get();
hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom());
}
// Música de fons en bucle infinit. El track ja està al Resource::Cache
// (preload al Director), així que playMusic només mou el punter cap a
@@ -486,6 +492,15 @@ void GameScene::loadRoom(const std::string& yaml_path) {
// el primer.
for (auto& p : players_) { p->disable(); }
// Refresca el marcador (piràmide / sala) amb el LevelManager. El
// GameScene no manté els valors a part: la lectura sempre és a
// través del singleton, així no es desincronitzen mai entre
// hotkeys de salt, debug.yaml i creuament natural de porta.
if (hud_) {
const auto& mgr = Levels::LevelManager::get();
hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom());
}
std::cout << "[ROOM] load " << yaml_path << ": entities reloaded, paint cleared, "
<< ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
}
@@ -498,7 +513,6 @@ void GameScene::advanceToNextRoom() {
std::cout << "[ROOM] final del levels.yaml — bucle a la primera sala\n";
mgr.restart();
}
room_index_ = mgr.currentRoom();
loadRoom(mgr.currentPath());
enterRoomIntro();
}
@@ -615,70 +629,83 @@ void GameScene::renderRoomFade() {
void GameScene::renderRoomText() {
// Text de la transició de sala. Reusa la font cachejada per a la
// pausa (mateix tipus de lletra, mateix índex de color), i fa el
// mateix patró esquerra → centre → esquerra que el text de pausa
// però amb les durades pròpies de la transició.
// mateix patró esquerra → centre → dreta que el text de pausa però
// amb les durades pròpies de la transició.
//
// Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; text = sala N
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; text = "fase completada"
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
// (piràmide damunt, sala davall) que comparteixen
// animació però es dibuixen amb writeDX independents
// per deixar la porta oberta a desacoblar-les.
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; una línia
// ("fase completada").
if (!pause_font_) { return; }
std::string text;
int phase_enter = 0;
int phase_exit = 0;
int dur_enter = 0;
int dur_exit = 0;
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_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
text = Locale::get().text("game.room_complete");
phase_enter = 0;
phase_exit = 2;
dur_enter = transition_config_.outro_text_enter_ms;
dur_exit = transition_config_.outro_text_exit_ms;
} else {
return;
}
constexpr int KERNING = 0;
const int W = pause_font_->length(text, KERNING);
// El text entra des de l'esquerra (off-screen amb el seu cantó dret
// a x=0) i, després del hold, surt cap a la dreta (off-screen amb
// el seu cantó esquerre a x=WIDTH). Així el text "travessa" la
// pantalla en una sola direcció en compte de tornar enrere.
const int OFF_LEFT = -W;
const int OFF_RIGHT = GameCanvas::WIDTH;
int x = GameCanvas::CENTER_X;
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
// Calcula la X animada d'una línia: entra des de l'esquerra
// (off-screen amb el seu cantó dret a x=0), s'atura al CENTER_X
// durant el hold i surt cap a la dreta (off-screen amb el cantó
// esquerre a x=WIDTH). El càlcul depèn de l'amplada de la línia
// — per això la passem com a paràmetre.
auto compute_x = [&](int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit) -> int {
const int OFF_LEFT = -width;
const int OFF_RIGHT = GameCanvas::WIDTH;
if (transition_.phase == phase_enter) {
const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
} else if (transition_.phase == phase_exit) {
return Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
}
if (transition_.phase == phase_exit) {
const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
}
return GameCanvas::CENTER_X;
};
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
auto draw_line = [&](const std::string& text, int x, int y) {
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
y,
text,
KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
};
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
const auto& mgr = Levels::LevelManager::get();
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom());
const int W_PYRAMID = pause_font_->length(LINE_PYRAMID, KERNING);
const int W_ROOM = pause_font_->length(LINE_ROOM, KERNING);
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
const int X_PYRAMID = compute_x(W_PYRAMID, 2, 4, DUR_ENTER, DUR_EXIT);
const int X_ROOM = compute_x(W_ROOM, 2, 4, DUR_ENTER, DUR_EXIT);
// Bloc de dues línies centrat al voltant de CENTER_Y: la
// piràmide ocupa la línia de damunt, la sala la de davall. Si
// un dia el font canvia d'alçada, `getLineHeight` ho reflecteix.
const int LINE_HEIGHT = pause_font_->getLineHeight();
const int Y_PYRAMID = GameCanvas::CENTER_Y - LINE_HEIGHT;
const int Y_ROOM = GameCanvas::CENTER_Y;
draw_line(LINE_PYRAMID, X_PYRAMID, Y_PYRAMID);
draw_line(LINE_ROOM, X_ROOM, Y_ROOM);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
const std::string TEXT = Locale::get().text("game.room_complete");
const int W = pause_font_->length(TEXT, KERNING);
const int X = compute_x(W, 0, 2, transition_config_.outro_text_enter_ms, transition_config_.outro_text_exit_ms);
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
draw_line(TEXT, X, Y);
}
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
@@ -833,7 +860,6 @@ void GameScene::update(int delta_ms) {
if (DELTA != 0) {
auto& mgr = Levels::LevelManager::get();
mgr.setIndex(mgr.index() + DELTA);
room_index_ = mgr.currentRoom();
loadRoom(mgr.currentPath());
enterRoomIntro();
}
-6
View File
@@ -183,12 +183,6 @@ class GameScene : public Scene {
// 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
// arribi l'itinerari, és el camp que es modificarà al transicionar
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
int room_index_{1};
// 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.
+18 -5
View File
@@ -107,15 +107,28 @@ namespace RoomFsm {
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);
namespace {
// Substitueix la primera ocurrència de `placeholder` dins de `tpl`
// pel valor d'`n` convertit a string. Utilitzat per les dues
// formatadores d'INTRO; si la clau del Locale no porta el
// placeholder (traducció buida o ja substituïda), retorna la
// plantilla intacta.
auto substitute(std::string tpl, std::string_view placeholder, int n) -> std::string {
auto pos = tpl.find(placeholder);
if (pos != std::string::npos) {
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
tpl.replace(pos, placeholder.size(), std::to_string(n));
}
return tpl;
}
} // namespace
auto formatIntroPyramidLine(int pyramid) -> std::string {
return substitute(Locale::get().text("game.room_intro_pyramid"), "{p}", pyramid);
}
auto formatIntroRoomLine(int room) -> std::string {
return substitute(Locale::get().text("game.room_intro_room"), "{r}", room);
}
auto stateName(State s) -> const char* {
switch (s) {
+9 -4
View File
@@ -66,10 +66,15 @@ namespace RoomFsm {
// (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;
// Substitueix `{p}` de la clau "game.room_intro_pyramid" del Locale
// per l'enter passat. Pensat per a la primera línia del text d'INTRO.
// Si la clau no està traduïda, retorna la pròpia clau (fallback de
// Locale::text).
[[nodiscard]] auto formatIntroPyramidLine(int pyramid) -> std::string;
// Equivalent per a `game.room_intro_room` amb el placeholder `{r}`.
// Es renderitza com a segona línia del text d'INTRO.
[[nodiscard]] auto formatIntroRoomLine(int room) -> std::string;
// Nom textual per a logs i tests. No es fa servir per a lògica.
[[nodiscard]] auto stateName(State s) -> const char*;
+16 -10
View File
@@ -8,8 +8,8 @@
// 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).
// T9 formatIntroPyramidLine / formatIntroRoomLine — substitució amb Locale.
// T10 formatIntroPyramidLine / formatIntroRoomLine — fallback sense Locale.
// T11 stateName — strings esperats per a cada State.
//
// El render no es testeja: la FSM és pura i no pinta res. Les regles de
@@ -189,23 +189,29 @@ namespace {
}
void testFormatRoomTitleWithLocale() {
std::cout << "T9 formatRoomTitle — substitució amb Locale\n";
std::cout << "T9 formatIntroPyramidLine / formatIntroRoomLine — 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");
writeTemp(PATH,
"game:\n"
" room_intro_pyramid: \"Piramide {p}\"\n"
" room_intro_room: \"Sala {r}\"\n");
Locale::get().load(PATH);
CHECK(RoomFsm::formatRoomTitle(1) == "Sala 1");
CHECK(RoomFsm::formatRoomTitle(7) == "Sala 7");
CHECK(RoomFsm::formatIntroPyramidLine(1) == "Piramide 1");
CHECK(RoomFsm::formatIntroPyramidLine(5) == "Piramide 5");
CHECK(RoomFsm::formatIntroRoomLine(1) == "Sala 1");
CHECK(RoomFsm::formatIntroRoomLine(7) == "Sala 7");
std::filesystem::remove(PATH);
}
void testFormatRoomTitleNoLocale() {
std::cout << "T10 formatRoomTitle — fallback sense traducció\n";
std::cout << "T10 formatIntroPyramidLine / formatIntroRoomLine — 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");
// Sense les claus, Locale retorna la pròpia clau i com que no
// porten `{p}`/`{r}`, la substitució no canvia res.
CHECK(RoomFsm::formatIntroPyramidLine(3) == "game.room_intro_pyramid");
CHECK(RoomFsm::formatIntroRoomLine(2) == "game.room_intro_room");
std::filesystem::remove(PATH);
}