From 5c70019eb5f17a9bc45ba038c7e514628680a645 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 11:56:38 +0200 Subject: [PATCH] feat(game): pausa graduada amb easing i separador al HUD --- data/locale/ca.yaml | 2 +- data/locale/en.yaml | 8 +-- data/ui.yaml | 11 +++ source/core/ui/ui_config.cpp | 47 ++++++++++++ source/core/ui/ui_config.hpp | 22 ++++++ source/game/scenes/game_scene.cpp | 116 ++++++++++++++++++++++++------ source/game/scenes/game_scene.hpp | 53 ++++++++++---- source/game/ui/hud.cpp | 49 +++++++------ source/utils/defaults.hpp | 11 +++ 9 files changed, 258 insertions(+), 61 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 873d385..968c319 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -32,7 +32,7 @@ hud: p1: "P1" p2: "P2" lives: "Vides" - treasures: "Tres." + treasures: "Tres" pyramid: "Piramide" room: "Sala" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 07bf00d..a5fe094 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -31,10 +31,10 @@ input: hud: p1: "P1" p2: "P2" - lives: "L" - treasures: "T" - pyramid: "PYRAMID" - room: "ROOM" + lives: "Lives" + treasures: "Tre" + pyramid: "Pyramid" + room: "Room" menu: titles: diff --git a/data/ui.yaml b/data/ui.yaml index d33e887..e50dd6f 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -52,3 +52,14 @@ hud: entry_ms: 800 # durada de l'animació d'entrada dels tres blocs flash_ms: 250 # durada del flash quan un jugador rep un hit shake_ms: 300 # durada del shake que acompanya el flash + +# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb +# easing i torna a arrancar amb una altra corba. Els timings es poden +# afinar independentment, i les corbes permeten estils diferents entrant +# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles: +# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic. +pause: + enter_ms: 400 + exit_ms: 250 + enter_curve: outCubic + exit_curve: inCubic diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index 638d84c..f982f0b 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -2,10 +2,13 @@ #include #include +#include +#include #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" #include "utils/defaults.hpp" +#include "utils/easing.hpp" namespace { @@ -30,6 +33,28 @@ namespace { dst = static_cast(VALUE); } + // Registre de corbes disponibles. Afegir-ne una nova és només una + // entrada ací: la llegeix `UiConfig::curveByName` i queda exposada al + // YAML pel seu nom. No hi ha fallback mut: si el nom no hi és, l'api + // retorna linear i avisa per stderr perquè l'error no passi en silenci. + auto curveRegistry() -> const std::unordered_map& { + static const std::unordered_map REGISTRY = { + {"linear", &Easing::linear}, + {"outQuad", &Easing::outQuad}, + {"inQuad", &Easing::inQuad}, + {"inOutQuad", &Easing::inOutQuad}, + {"outCubic", &Easing::outCubic}, + {"inCubic", &Easing::inCubic}, + }; + return REGISTRY; + } + + void readCurve(const fkyaml::node& node, const char* key, UiConfig::CurveFn& dst) { + if (!node.contains(key)) { return; } + const auto NAME = node[key].get_value(); + dst = UiConfig::curveByName(NAME); + } + void readFont(const fkyaml::node& node, const char* key, UiConfig::Font& dst) { if (!node.contains(key)) { return; } const auto& f = node[key]; @@ -65,6 +90,10 @@ UiConfig::UiConfig() hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS), hud_flash_ms_(Defaults::UI::HUD_FLASH_MS), hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS), + pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS), + pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), + pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)), + pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)), hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING}, placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING}, menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING}, @@ -76,6 +105,16 @@ auto UiConfig::get() -> UiConfig& { return instance_; } +auto UiConfig::curveByName(const std::string& name) -> CurveFn { + const auto& reg = curveRegistry(); + const auto IT = reg.find(name); + if (IT == reg.end()) { + std::cerr << "UiConfig: corba '" << name << "' desconeguda — s'usa linear\n"; + return &Easing::linear; + } + return IT->second; +} + void UiConfig::load(const std::string& file_path) { auto bytes = Resource::Helper::loadFile(file_path); if (bytes.empty()) { @@ -132,6 +171,14 @@ void UiConfig::load(const std::string& file_path) { readField(h, "shake_ms", hud_shake_ms_); } + if (yaml.contains("pause")) { + const auto& p = yaml["pause"]; + readField(p, "enter_ms", pause_enter_ms_); + readField(p, "exit_ms", pause_exit_ms_); + readCurve(p, "enter_curve", pause_enter_curve_); + readCurve(p, "exit_curve", pause_exit_curve_); + } + std::cout << "UiConfig: carregat des de " << file_path << '\n'; } catch (const std::exception& e) { std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n'; diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index ae25784..a856eb8 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -49,12 +49,30 @@ class UiConfig { [[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; } [[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; } + // Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1]. + // Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar + // el punter a la corresponent funció de `utils/easing.hpp`. + using CurveFn = auto (*)(float) -> float; + // Timings (ms). [[nodiscard]] auto notificationMs() const -> int { return notification_ms_; } [[nodiscard]] auto exitMs() const -> int { return exit_ms_; } [[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; } [[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; } [[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; } + [[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } + [[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } + + // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa + // Easing::linear com a fallback. + [[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; } + [[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; } + + // Resol un nom de corba a la seua funció. Pública per permetre a + // futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD + // entry, notifications slide...). Si el nom no està registrat, + // retorna Easing::linear i deixa un warning al log. + [[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn; // Fonts de UI. [[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; } @@ -93,6 +111,10 @@ class UiConfig { int hud_entry_ms_; int hud_flash_ms_; int hud_shake_ms_; + int pause_enter_ms_; + int pause_exit_ms_; + CurveFn pause_enter_curve_; + CurveFn pause_exit_curve_; Font hud_font_; Font placeholder_font_; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index fa7dbe9..34009fa 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include "game/ui/hud.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" +#include "utils/easing.hpp" namespace { @@ -178,12 +180,69 @@ void GameScene::handlePauseInput() { const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC); const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START); if (!KEY_HIT && !PAD_HIT) { continue; } - paused_ = !paused_; - if (paused_) { - Audio::get()->pauseMusic(); - } else { + + // Determinem cap a quin estat anem. Si estem quiets (IDLE o + // PAUSED) arranquem la transició contrària. Si estem en marxa + // (ENTERING o EXITING) invertim: la `scale` actual es manté com + // a `from_scale` nou i la durada es recalcula proporcional a la + // distància restant — així mai es produeix un salt de velocitat. + const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING); + + // Reprenem l'stream ABANS de començar a pujar el volum: quan + // venim de PAUSED l'àudio està aturat de debò (ho fem al final + // d'ENTERING, veure updatePauseTransition); reprenem just al + // moment de començar EXITING per no perdre el primer frame + // d'stream. A les inversions mid-flight (ENTERING → EXITING + // sense tocar PAUSED) l'stream no s'ha aturat mai, així que no + // cal reprendre. + if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) { Audio::get()->resumeMusic(); } + + pause_.from_scale = pause_.scale; + pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F; + const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs(); + const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale); + pause_.duration_ms = std::max(1, static_cast(static_cast(TARGET_MS) * DISTANCE)); + pause_.elapsed_ms = 0; + pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve(); + pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING; + + std::cout << "[PAUSE] " << (GOING_TO_PAUSE ? "entering" : "exiting") << " from_scale=" << pause_.from_scale << " duration_ms=" << pause_.duration_ms << '\n'; + } +} + +void GameScene::updatePauseTransition(int delta_ms) { + if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; } + + pause_.elapsed_ms += delta_ms; + bool just_reached_pause = false; + if (pause_.elapsed_ms >= pause_.duration_ms) { + // Transició completa: enganxem al valor objectiu exacte i + // promocionem l'estat al terminal corresponent. + pause_.scale = pause_.to_scale; + const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE; + just_reached_pause = (NEXT == PauseState::PAUSED); + pause_.state = NEXT; + } else { + const float T = static_cast(pause_.elapsed_ms) / static_cast(pause_.duration_ms); + const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear; + pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T)); + } + + // Fade del volum de música durant la transició. La base és el que + // l'usuari ha configurat a Options; multipliquem per la `scale` per + // obtenir el volum efectiu aquest frame. + const float BASE_VOLUME = Options::audio().music.volume; + Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale); + + // En arribar a PAUSED aturem l'stream: així quan despausem la música + // tornarà des d'on s'ha quedat, no des d'on hauria anat a parar + // durant el temps que el joc ha estat en pausa. El `resumeMusic` es + // fa a `handlePauseInput` al començar l'EXITING, perquè la música + // sone ja mentre el volum puja des de 0. + if (just_reached_pause) { + Audio::get()->pauseMusic(); } } @@ -201,12 +260,13 @@ namespace { void GameScene::update(int delta_ms) { handlePauseInput(); + updatePauseTransition(delta_ms); #ifdef _DEBUG - // Toggle de l'editor: només si la pausa no està activa (mútuament - // excloents). L'editor mateix no consumeix la tecla — la llegim ací - // per decidir la coexistència. - if (!paused_ && detectEditorToggle()) { + // Toggle de l'editor: només si no hi ha cap transició de pausa ni + // pausa consolidada. L'editor mateix no consumeix la tecla — la + // llegim ací per decidir la coexistència. + if (pause_.state == PauseState::IDLE && detectEditorToggle()) { editor_.toggle(tilemap_); } #endif @@ -214,16 +274,19 @@ void GameScene::update(int delta_ms) { GlobalInputs::update(delta_ms); if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } - // El món es congela durant la pausa i durant el mode editor: ni - // jugadors ni entitats avancen, ni es resolen col·lisions. Notifier i - // RenderInfo sí continuen perquè els usa la UI de sistema. + // El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat + // real, scale=0 el congela. Quan l'editor de debug està actiu també + // es salta completament (no l'escalem, el saltem). Notifier, + // RenderInfo i HUD reben el delta real, per això viuen fora d'aquest + // bloc i no es ralenteixen amb el dial de pausa. #ifdef _DEBUG - const bool WORLD_FROZEN = paused_ || editor_.active(); + const bool EDITOR_FROZEN = editor_.active(); #else - const bool WORLD_FROZEN = paused_; + constexpr bool EDITOR_FROZEN = false; #endif - if (!WORLD_FROZEN) { - const float DT = static_cast(delta_ms) / 1000.0F; + const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); + if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { + const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; for (auto& player : players_) { player->update(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 @@ -318,19 +381,28 @@ void GameScene::render() { editor_.render(tilemap_); #endif - // Overlay de pausa: text centrat amb stroke de 8 veïns (STROKE_FLAG) - // que fa de contorn. Fem el contorn blanc (índex 255 de sega-cube.pal) - // i el cos en color de notificació, prou contrastat amb el fons del - // nivell (parets fosques). Text::writeDX aplica el stroke abans que - // el fill, així que l'ordre visual queda correcte sense passos extra. - if (paused_ && pause_font_) { + // Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X + // depenent de l'estat del dial. Entra lliscant des de l'esquerra + // (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta + // (EXITING). La X es deriva directament de `pause_.scale`, que ja + // porta la corba aplicada — així text i món frenen alhora. + if (pause_.state != PauseState::IDLE && pause_font_) { constexpr Uint8 OUTLINE_WHITE = 255; constexpr Uint8 STROKE_DIST = 1; constexpr int PAUSE_KERNING = 0; const std::string TEXT = Locale::get().text("input.paused"); + const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING); + const int OFF_LEFT = -TEXT_WIDTH; + const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH; + int x = GameCanvas::CENTER_X; + if (pause_.state == PauseState::ENTERING) { + x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale); + } else if (pause_.state == PauseState::EXITING) { + x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale); + } const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2); pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, - GameCanvas::CENTER_X, + x, Y, TEXT, PAUSE_KERNING, diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index e576181..ccabfbc 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include "core/scene/scene.hpp" +#include "core/ui/ui_config.hpp" #include "game/entities/entity_manager.hpp" #include "game/map/paint_layer.hpp" #include "game/map/tilemap.hpp" @@ -39,10 +41,16 @@ class GameScene : public Scene { private: // Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó - // START del gamepad primari) per alternar l'estat de pausa. Quan - // s'activa, pausa la música; en reprendre, la reprén. + // START del gamepad primari) per disparar les transicions de pausa. + // No alterna un flag binari: inicia (o inverteix mid-flight) un + // "dial" de temps que frena el món amb easing. void handlePauseInput(); + // Avança la transició activa, recalcula `pause_.scale` i aplica el + // fade de volum de música. S'executa cada frame amb el delta real + // (no escalat) perquè la transició viu en temps real. + void updatePauseTransition(int delta_ms); + Config config_; Tilemap tilemap_; @@ -72,19 +80,40 @@ class GameScene : public Scene { // la destrucció. std::unique_ptr hud_; - // Pausa de gameplay. Quan `paused_` és true, l'update de jugadors i - // entitats es salta (el món es congela); l'input d'UI (GlobalInputs, - // Notifier, RenderInfo) continua per no perdre hotkeys ni l'overlay de - // debug. La font s'instancia des del Surface ja cachejat al Director - // (cap altra escena l'allibera, així que l'accés és segur). + // Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor, + // `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una + // corba d'easing durant ENTERING, i al revés durant EXITING. El + // `delta_ms` del món (players + EntityManager) es multiplica per + // aquesta escala; HUD, Notifier i GlobalInputs reben sempre el + // delta real. Les inversions mid-flight capturen la `scale` actual + // com a `from_scale` i escalen la durada a la distància restant per + // mantenir la sensació de velocitat. La font és la mateixa que + // abans — ja cachejada al Director. std::shared_ptr pause_font_; - bool paused_{false}; + + enum class PauseState : std::uint8_t { + IDLE, // Joc corrent; cap transició en marxa. scale == 1. + ENTERING, // Frenant cap a la pausa. scale decreix a 0. + PAUSED, // Món congelat. scale == 0. + EXITING, // Accelerant de tornada. scale creix a 1. + }; + + struct PauseTransition { + PauseState state{PauseState::IDLE}; + float scale{1.0F}; // 0 = parat, 1 = corrent. + float from_scale{1.0F}; // Valor de `scale` al començar la transició. + float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0). + int elapsed_ms{0}; // Temps transcorregut dins la transició. + int duration_ms{0}; // Durada total (escalada a la distància). + UiConfig::CurveFn curve{nullptr}; // Corba activa. + } pause_; #ifdef _DEBUG - // Editor modal del tilemap. Mútuament excloent amb la pausa: si `paused_` - // la tecla de toggle de l'editor s'ignora, i viceversa. Quan - // `editor_.active()` és true, saltem entitats i jugador (update i - // render) i deixem el tilemap visible perquè es pugui editar. + // 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 + // viceversa. Quan `editor_.active()` és true, saltem entitats i + // jugador (update i render) i deixem el tilemap visible perquè es + // pugui editar. Editor editor_; #endif }; diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp index 21b44d7..1f1cb02 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -32,9 +32,13 @@ namespace { // ho fa igual amb `writeMonoDigits`). constexpr int MONO_DIGIT_W = 6; - // Separadors visuals entre etiquetes i valors. - constexpr int LABEL_GAP = 2; // px entre "P1" i "000000" - constexpr int FIELD_GAP = 6; // px entre "V 3" i "T 00" + // Separador que s'enganxa al final de l'etiqueta abans del valor + // ("P1 000000", "Vides 3"...). Canviant aquesta cadena aquí es + // controla el look-and-feel de totes les línies del HUD. + constexpr const char* SEPARATOR = " "; + + // Separació extra entre camps dins d'una mateixa línia. + constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00" // Format zero-padded a 6 dígits per a la puntuació. auto formatScore(int v) -> std::string { @@ -229,56 +233,57 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index const auto& p = players_[static_cast(player_index)]; const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue(); - // --- Línia 1: "