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:
1 parent
b18bd4d021
commit
e2f6d326aa
7 files changed
+149
-99
No files matched your search
+6
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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* {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user