From 67b06c0a109f6a5a2ef3faf6af8227ee4eb4ab30 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 09:00:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(game-scene):=20ROOM=5FINTRO=20=E2=80=94=20?= =?UTF-8?q?fade=20in=20del=20m=C3=B3n=20i=20text=20d'entrada=20de=20sala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 194 ++++++++++++++++++++++++------ source/game/scenes/game_scene.hpp | 23 ++++ 2 files changed, 179 insertions(+), 38 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index be6254e..9528bba 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -3,11 +3,14 @@ #include #include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -490,18 +493,34 @@ void GameScene::setState(State new_state) { if (new_state == state_) { return; } std::cout << "[ROOM] " << stateName(state_) << " -> " << stateName(new_state) << '\n'; state_ = new_state; - // Els acumuladors de la transició de sala (sub-fase, elapsed) viuran - // aquí als commits següents. De moment no en tenim cap. + transition_ = {}; // sub-fase i elapsed s'inicialitzen a zero a cada estat. } -void GameScene::updateRoomTransition(int /*delta_ms*/) { - // Implementació mínima: no hi ha sub-fases ni fades encara, així que - // ROOM_INTRO i ROOM_OUTRO són efectivament instantanis. El bucle de - // sala queda demostrat (INTRO → PLAYING; al level_complete OUTRO → - // INTRO → PLAYING) sense efectes visuals. Els commits 3 i 4 - // afegiran timings i sub-fases reals. +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) { - setState(State::PLAYING); + const std::array 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(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) { + transition_.elapsed_ms -= DURS[transition_.phase]; + ++transition_.phase; + } + if (transition_.phase >= static_cast(DURS.size())) { + setState(State::PLAYING); + } return; } if (state_ == State::ROOM_OUTRO) { @@ -510,12 +529,83 @@ void GameScene::updateRoomTransition(int /*delta_ms*/) { // 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. + // tilemap/entitats) abans de tornar a ROOM_INTRO. La + // implementació de les sub-fases d'OUTRO arribarà al commit 4. 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; +} + +void GameScene::renderRoomFade() { + // Fade negre opac → transparent durant ROOM_INTRO/fase 0. Pintat + // sobre el món un cop els actors ja s'han dibuixat. Al commit 4 + // s'afegirà el fade out simètric per a ROOM_OUTRO/fase final. + if (state_ != State::ROOM_INTRO || transition_.phase != 0) { return; } + const int DUR = transition_config_.intro_fade_in_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 0.0F, 1.0F) + : 1.0F; + const auto ALPHA = static_cast(255.0F * (1.0F - T)); + if (ALPHA == 0) { return; } + Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA); +} + +void GameScene::renderRoomText() { + // Text d'entrada de sala. Visible només durant les sub-fases 2-4 + // de ROOM_INTRO (enter, hold, exit). Reusa la font cachejada per + // a la pausa: mateix tipus de lletra i mateix índex de color. + if (state_ != State::ROOM_INTRO || transition_.phase < 2 || transition_.phase > 4) { return; } + if (!pause_font_) { return; } + + const std::string TEXT = formatRoomTitle(room_index_); + constexpr int KERNING = 0; + const int W = pause_font_->length(TEXT, KERNING); + const int OFF_LEFT = -W; + int x = GameCanvas::CENTER_X; + + if (transition_.phase == 2) { + const int DUR = transition_config_.intro_text_enter_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 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 == 4) { + const int DUR = transition_config_.intro_text_exit_ms; + const float T = (DUR > 0) + ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(DUR), 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_LEFT, 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); +} + void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { // Mida del sprite de Dust llegida del seu YAML. Hardcoded per // simplificar — si canvia, només cal tocar dust.yaml + aquesta @@ -661,22 +751,29 @@ void GameScene::update(int delta_ms) { constexpr bool EDITOR_FROZEN = false; #endif const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); + const bool GAMEPLAY_ACTIVE = (state_ == State::PLAYING); if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; - // Pipeline de moviment en tres passes per poder intercalar la - // resolució de contactes jugador↔jugador entre input i posició: - // 1) tick: cada jugador calcula la velocitat intencionada - // (teclat + mando + facing). - // 2) resolve: si els dos es trobarien, anul·lem velocitats - // (head-on) o escalem a push_factor (empenta). - // 3) applyMovement: en l'ordre "pushed primer" perquè quan el - // pusher faça `tryMove`, el pushed ja estiga fora de la - // seua caixa. La callback també tracta l'altre jugador com - // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. - for (auto& player : players_) { player->tick(DT); } - resolvePlayerVsPlayer(players_view_, DT, push_factor_); - for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } + // Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni + // poden col·lisionar amb entitats. Les entitats sí, perquè el + // disseny demana que el món continui animant-se durant les + // transicions de sala. + if (GAMEPLAY_ACTIVE) { + // Pipeline de moviment en tres passes per poder intercalar la + // resolució de contactes jugador↔jugador entre input i posició: + // 1) tick: cada jugador calcula la velocitat intencionada + // (teclat + mando + facing). + // 2) resolve: si els dos es trobarien, anul·lem velocitats + // (head-on) o escalem a push_factor (empenta). + // 3) applyMovement: en l'ordre "pushed primer" perquè quan el + // pusher faça `tryMove`, el pushed ja estiga fora de la + // seua caixa. La callback també tracta l'altre jugador com + // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. + for (auto& player : players_) { player->tick(DT); } + resolvePlayerVsPlayer(players_view_, DT, push_factor_); + for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } + } // El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha // emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat @@ -687,7 +784,9 @@ void GameScene::update(int delta_ms) { // Detecció de contacte player↔entitat després que totes les entitats // hagin mogut aquest frame. Reutilitzem la vista plana que ja // manté el GameScene per al resolver player↔player. - entity_manager_.checkPlayerCollisions(players_view_); + if (GAMEPLAY_ACTIVE) { + entity_manager_.checkPlayerCollisions(players_view_); + } } #ifdef _DEBUG @@ -726,12 +825,19 @@ void GameScene::render() { // explícita: l'editor edita el tilemap, no la capa de pintat). if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS) { - // Passada de sombres dels jugadors saltant. Va per davant del - // pintat (al damunt) però per darrere de TOTS els actors — - // entitats i jugadors, inclòs el saltador mateix— així la sombra - // queda al sòl sense tapar res. `renderShadow` és no-op si el - // jugador està a terra, així que no cal filtrar al callsite. - for (const auto& p : players_) { p->renderShadow(); } + // 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); + + if (RENDER_PLAYERS) { + // Passada de sombres dels jugadors saltant. Va per davant del + // pintat (al damunt) però per darrere de TOTS els actors — + // entitats i jugadors, inclòs el saltador mateix— així la sombra + // queda al sòl sense tapar res. `renderShadow` és no-op si el + // jugador està a terra, així que no cal filtrar al callsite. + for (const auto& p : players_) { p->renderShadow(); } + } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // els actors amb els peus més amunt queden dibuixats primer (al fons), // els més avall al davant. Així un sarc situat per darrere d'un player @@ -742,14 +848,16 @@ void GameScene::render() { Player* player; // nullable; si és nullptr, s'usa `entity`. }; std::vector order; - order.reserve(entity_manager_.entityCount() + players_.size()); + order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0)); const auto ENTITIES = entity_manager_.getEntities(); std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; }); - std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { - return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; - }); + if (RENDER_PLAYERS) { + std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { + return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; + }); + } std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { return a.feet_y < b.feet_y; }); @@ -760,11 +868,21 @@ void GameScene::render() { r.player->render(); } } - // HUD: a sobre dels actors perquè la franja superior tape qualsevol - // sprite que (durant l'animació d'entrada) puga sortir més amunt - // del playfield. S'amaga en mode editor (l'editor edita el tilemap; - // el HUD distrauria) però segueix actualitzant-se per fora. + + // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les + // notificacions i el service menu queden per damunt sense afectar-se. + // Inactiu si l'estat és PLAYING. + renderRoomFade(); + + // HUD: a sobre dels actors (i del fade) perquè la franja superior + // tape qualsevol sprite que (durant l'animació d'entrada) puga + // sortir més amunt del playfield. S'amaga en mode editor però + // segueix actualitzant-se per fora. if (hud_) { hud_->render(); } + + // Text de la transició de sala (entrada o sortida): per damunt del + // HUD perquè és UI de transició i ha de ser sempre llegible. + renderRoomText(); } #ifdef _DEBUG diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 3dd06e6..50b73a4 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -185,12 +185,35 @@ class GameScene : public Scene { // la sala. La transició a PLAYING l'orquestra `updateRoomTransition`. State state_{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_{}; + // Í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}; + // 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. + void renderRoomFade(); + void renderRoomText(); + #ifdef _DEBUG // Editor modal del tilemap. Mútuament excloent amb la pausa: si la // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i