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
+149 -99

No files matched your search

+91 -65
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;
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) {
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));
}
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
text,
KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
// 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;
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;
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
}
return GameCanvas::CENTER_X;
};
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,
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.
+20 -7
View File
@@ -107,14 +107,27 @@ 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);
if (pos != std::string::npos) {
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
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));
}
return tpl;
}
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* {
+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*;