feat(game): pausa graduada amb easing i separador al HUD

This commit is contained in:
JailDesigner committed 2026-04-24 11:56:38 +02:00
1 parent 8aa4ec3daf
commit 5c70019eb5
9 files changed
+258 -61

No files matched your search

+94 -22
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <filesystem>
#include <iostream>
@@ -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<int>(static_cast<float>(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<float>(pause_.elapsed_ms) / static_cast<float>(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<float>(delta_ms) / 1000.0F;
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(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,
+41 -12
View File
@@ -1,10 +1,12 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#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> 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<Text> 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
};