From 81330f8432895febf62ce41095cdcaccff275810 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 20 May 2026 22:07:56 +0200 Subject: [PATCH] feat(notifier): infrastructura del sistema de notificacions toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notifier singleton (System::init/get/destroy) que dibuixa un cuadre centrat al centre-superior amb fons semitransparent (derivat oscur del color del text) i bordes en línies. - Màquina d'estats HIDDEN → ENTERING → HOLDING → EXITING amb easing outCubic (entrada) i inCubic (sortida), slide de 300 ms. - pushRect() afegit a GpuFrameRenderer (2 triangles, edge_dist=0) per poder pintar el fons opac/semitransparent reutilitzant el pipeline de línies — sense afegir cap pipeline nou. - VectorText::render/renderCentered admeten color RGBA explícit (default {0,0,0,0} preserva el comportament previ amb oscil·lador global de color). - Easing header-only a core/utils/easing.hpp (outCubic, inCubic). - Director crea Notifier just després del DebugOverlay i el draweja com a última capa per damunt de l'escena i el debug. Encara cap consumer el crida; els F1-F5 i la doble pulsació d'ESC arriben en commits posteriors. Co-Authored-By: Claude Opus 4.7 (1M context) --- source/core/graphics/vector_text.cpp | 498 +++++++++--------- source/core/graphics/vector_text.hpp | 17 +- .../core/rendering/gpu/gpu_frame_renderer.cpp | 25 + .../core/rendering/gpu/gpu_frame_renderer.hpp | 7 + source/core/system/director.cpp | 15 +- source/core/system/notifier.cpp | 186 +++++++ source/core/system/notifier.hpp | 81 +++ source/core/utils/easing.hpp | 24 + 8 files changed, 595 insertions(+), 258 deletions(-) create mode 100644 source/core/system/notifier.cpp create mode 100644 source/core/system/notifier.hpp create mode 100644 source/core/utils/easing.hpp diff --git a/source/core/graphics/vector_text.cpp b/source/core/graphics/vector_text.cpp index 9ac58c3..a6f1d9c 100644 --- a/source/core/graphics/vector_text.cpp +++ b/source/core/graphics/vector_text.cpp @@ -10,274 +10,274 @@ namespace Graphics { -// Constants para mides base dels caràcters -constexpr float BASE_CHAR_WIDTH = 20.0F; // Amplada base del caràcter -constexpr float BASE_CHAR_HEIGHT = 40.0F; // Altura base del caràcter + // Constants para mides base dels caràcters + constexpr float BASE_CHAR_WIDTH = 20.0F; // Amplada base del caràcter + constexpr float BASE_CHAR_HEIGHT = 40.0F; // Altura base del caràcter -VectorText::VectorText(Rendering::Renderer* renderer) - : renderer_(renderer) { - loadCharset(); -} + VectorText::VectorText(Rendering::Renderer* renderer) + : renderer_(renderer) { + loadCharset(); + } -void VectorText::loadCharset() { - // Cargar dígitos 0-9 - for (char c = '0'; c <= '9'; c++) { - std::string filename = getShapeFilename(c); - auto shape = ShapeLoader::load(filename); + void VectorText::loadCharset() { + // Cargar dígitos 0-9 + for (char c = '0'; c <= '9'; c++) { + std::string filename = getShapeFilename(c); + auto shape = ShapeLoader::load(filename); - if (shape && shape->isValid()) { - chars_[c] = shape; - } else { - std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename - << '\n'; + if (shape && shape->isValid()) { + chars_[c] = shape; + } else { + std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename + << '\n'; + } + } + + // Cargar lletres A-Z (majúscules) + for (char c = 'A'; c <= 'Z'; c++) { + std::string filename = getShapeFilename(c); + auto shape = ShapeLoader::load(filename); + + if (shape && shape->isValid()) { + chars_[c] = shape; + } else { + std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename + << '\n'; + } + } + + // Cargar símbolos + const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?"}; + for (const auto& sym : SYMBOLS) { + char c = sym[0]; + std::string filename = getShapeFilename(c); + auto shape = ShapeLoader::load(filename); + + if (shape && shape->isValid()) { + chars_[c] = shape; + } else { + std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename + << '\n'; + } + } + + // Cargar símbolo de copyright (©) - UTF-8 U+00A9. + // Usamos el segundo byte (0xA9, 169 decimal) como key interna del map. + { + const std::string FILENAME = "font/char_copyright.shp"; + auto shape = ShapeLoader::load(FILENAME); + + if (shape && shape->isValid()) { + chars_['\xA9'] = shape; + } else { + std::cerr << "[VectorText] Warning: no s'ha pogut load " << FILENAME + << '\n'; + } + } + + std::cout << "[VectorText] Carregats " << chars_.size() << " caràcters" + << '\n'; + } + + auto VectorText::getShapeFilename(char c) -> std::string { + // Mapeo carácter → nombre de archivo (con prefix "font/"). + // Dígitos 0-9 y mayúsculas A-Z comparten el mismo path: la shape se llama + // como el caracter mismo, así que se agrupan en un único case. + switch (c) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + case 'G': + case 'H': + case 'I': + case 'J': + case 'K': + case 'L': + case 'M': + case 'N': + case 'O': + case 'P': + case 'Q': + case 'R': + case 'S': + case 'T': + case 'U': + case 'V': + case 'W': + case 'X': + case 'Y': + case 'Z': + return std::string("font/char_") + c + ".shp"; + + // Lletres minúscules a-z (convertir a majúscules) + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + case 'g': + case 'h': + case 'i': + case 'j': + case 'k': + case 'l': + case 'm': + case 'n': + case 'o': + case 'p': + case 'q': + case 'r': + case 's': + case 't': + case 'u': + case 'v': + case 'w': + case 'x': + case 'y': + case 'z': + return std::string("font/char_") + char(c - 32) + ".shp"; + + // Símbols + case '.': + return "font/char_dot.shp"; + case ',': + return "font/char_comma.shp"; + case '-': + return "font/char_minus.shp"; + case ':': + return "font/char_colon.shp"; + case '!': + return "font/char_exclamation.shp"; + case '?': + return "font/char_question.shp"; + case ' ': + return ""; // Espai es maneja sin load shape + + case '\xA9': // Copyright symbol (©) - UTF-8 U+00A9 + return "font/char_copyright.shp"; + + default: + return ""; // Caràcter no suportat } } - // Cargar lletres A-Z (majúscules) - for (char c = 'A'; c <= 'Z'; c++) { - std::string filename = getShapeFilename(c); - auto shape = ShapeLoader::load(filename); + auto VectorText::isSupported(char c) const -> bool { + return chars_.contains(c); + } - if (shape && shape->isValid()) { - chars_[c] = shape; - } else { - std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename - << '\n'; + void VectorText::render(const std::string& text, const Vec2& position, float scale, float spacing, float brightness, SDL_Color color) const { + if (renderer_ == nullptr) { + return; + } + + // Ancho de un carácter base (20 px a scale 1.0) + const float CHAR_WIDTH_SCALED = BASE_CHAR_WIDTH * scale; + + // Spacing escalado + const float SPACING_SCALED = spacing * scale; + + // Altura de un carácter escalado (necesario para ajustar Y) + const float CHAR_HEIGHT_SCALED = BASE_CHAR_HEIGHT * scale; + + // Posición X del borde izquierdo del carácter actual + // (se ajustará +BASE_CHAR_WIDTH/2 para obtener el centro al renderizar) + float current_x = position.x; + + // Iterar sobre cada byte del string (con detecció UTF-8) + for (size_t i = 0; i < text.length(); i++) { + auto c = static_cast(text[i]); + + // Detectar copyright UTF-8 (0xC2 0xA9) + if (c == 0xC2 && i + 1 < text.length() && + static_cast(text[i + 1]) == 0xA9) { + c = 0xA9; // Usar segon byte como a key + i++; // Saltar el següent byte + } + + // Manejar espacios (avanzar sin dibujar) + if (c == ' ') { + current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; + continue; + } + + // Verificar si el carácter está soportado + auto it = chars_.find(c); + if (it != chars_.end()) { + // Renderizar carácter + // Ajustar X e Y para que position represente esquina superior izquierda + // (render_shape espera el centro, así que sumamos la mitad de ancho y altura) + Vec2 char_pos = {.x = current_x + (CHAR_WIDTH_SCALED / 2.0F), .y = position.y + (CHAR_HEIGHT_SCALED / 2.0F)}; + Rendering::renderShape(renderer_, it->second, char_pos, 0.0F, scale, 1.0F, brightness, color); + + // Avanzar posición + current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; + } else { + // Carácter no soportado: saltar (o renderizar '?' en el futuro) + std::cerr << "[VectorText] Warning: caràcter no suportat '" << c << "'" + << '\n'; + current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; + } } } - // Cargar símbolos - const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?"}; - for (const auto& sym : SYMBOLS) { - char c = sym[0]; - std::string filename = getShapeFilename(c); - auto shape = ShapeLoader::load(filename); + void VectorText::renderCentered(const std::string& text, const Vec2& centre_punt, float scale, float spacing, float brightness, SDL_Color color) const { + // Calcular dimensions del text + float text_width = getTextWidth(text, scale, spacing); + float text_height = getTextHeight(scale); - if (shape && shape->isValid()) { - chars_[c] = shape; - } else { - std::cerr << "[VectorText] Warning: no s'ha pogut load " << filename - << '\n'; - } + // Calcular posición de l'esquina superior izquierda + // restant la meitat de las dimensions del point central + Vec2 posicio_esquerra = { + .x = centre_punt.x - (text_width / 2.0F), + .y = centre_punt.y - (text_height / 2.0F)}; + + // Delegar al método render() existent + render(text, posicio_esquerra, scale, spacing, brightness, color); } - // Cargar símbolo de copyright (©) - UTF-8 U+00A9. - // Usamos el segundo byte (0xA9, 169 decimal) como key interna del map. - { - const std::string FILENAME = "font/char_copyright.shp"; - auto shape = ShapeLoader::load(FILENAME); - - if (shape && shape->isValid()) { - chars_['\xA9'] = shape; - } else { - std::cerr << "[VectorText] Warning: no s'ha pogut load " << FILENAME - << '\n'; - } - } - - std::cout << "[VectorText] Carregats " << chars_.size() << " caràcters" - << '\n'; -} - -auto VectorText::getShapeFilename(char c) -> std::string { - // Mapeo carácter → nombre de archivo (con prefix "font/"). - // Dígitos 0-9 y mayúsculas A-Z comparten el mismo path: la shape se llama - // como el caracter mismo, así que se agrupan en un único case. - switch (c) { - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case 'A': - case 'B': - case 'C': - case 'D': - case 'E': - case 'F': - case 'G': - case 'H': - case 'I': - case 'J': - case 'K': - case 'L': - case 'M': - case 'N': - case 'O': - case 'P': - case 'Q': - case 'R': - case 'S': - case 'T': - case 'U': - case 'V': - case 'W': - case 'X': - case 'Y': - case 'Z': - return std::string("font/char_") + c + ".shp"; - - // Lletres minúscules a-z (convertir a majúscules) - case 'a': - case 'b': - case 'c': - case 'd': - case 'e': - case 'f': - case 'g': - case 'h': - case 'i': - case 'j': - case 'k': - case 'l': - case 'm': - case 'n': - case 'o': - case 'p': - case 'q': - case 'r': - case 's': - case 't': - case 'u': - case 'v': - case 'w': - case 'x': - case 'y': - case 'z': - return std::string("font/char_") + char(c - 32) + ".shp"; - - // Símbols - case '.': - return "font/char_dot.shp"; - case ',': - return "font/char_comma.shp"; - case '-': - return "font/char_minus.shp"; - case ':': - return "font/char_colon.shp"; - case '!': - return "font/char_exclamation.shp"; - case '?': - return "font/char_question.shp"; - case ' ': - return ""; // Espai es maneja sin load shape - - case '\xA9': // Copyright symbol (©) - UTF-8 U+00A9 - return "font/char_copyright.shp"; - - default: - return ""; // Caràcter no suportat - } -} - -auto VectorText::isSupported(char c) const -> bool { - return chars_.contains(c); -} - -void VectorText::render(const std::string& text, const Vec2& position, float scale, float spacing, float brightness) const { - if (renderer_ == nullptr) { - return; - } - - // Ancho de un carácter base (20 px a scale 1.0) - const float CHAR_WIDTH_SCALED = BASE_CHAR_WIDTH * scale; - - // Spacing escalado - const float SPACING_SCALED = spacing * scale; - - // Altura de un carácter escalado (necesario para ajustar Y) - const float CHAR_HEIGHT_SCALED = BASE_CHAR_HEIGHT * scale; - - // Posición X del borde izquierdo del carácter actual - // (se ajustará +BASE_CHAR_WIDTH/2 para obtener el centro al renderizar) - float current_x = position.x; - - // Iterar sobre cada byte del string (con detecció UTF-8) - for (size_t i = 0; i < text.length(); i++) { - auto c = static_cast(text[i]); - - // Detectar copyright UTF-8 (0xC2 0xA9) - if (c == 0xC2 && i + 1 < text.length() && - static_cast(text[i + 1]) == 0xA9) { - c = 0xA9; // Usar segon byte como a key - i++; // Saltar el següent byte + auto VectorText::getTextWidth(const std::string& text, float scale, float spacing) -> float { + if (text.empty()) { + return 0.0F; } - // Manejar espacios (avanzar sin dibujar) - if (c == ' ') { - current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; - continue; + const float CHAR_WIDTH_SCALED = BASE_CHAR_WIDTH * scale; + const float SPACING_SCALED = spacing * scale; + + // Contar caracteres visuals (no bytes) - manejar UTF-8 + size_t visual_chars = 0; + for (size_t i = 0; i < text.length(); i++) { + auto c = static_cast(text[i]); + + // Detectar copyright UTF-8 (0xC2 0xA9) - igual que render() + if (c == 0xC2 && i + 1 < text.length() && + static_cast(text[i + 1]) == 0xA9) { + visual_chars++; // Un caràcter visual (©) + i++; // Saltar el següent byte + } else { + visual_chars++; // Caràcter normal + } } - // Verificar si el carácter está soportado - auto it = chars_.find(c); - if (it != chars_.end()) { - // Renderizar carácter - // Ajustar X e Y para que position represente esquina superior izquierda - // (render_shape espera el centro, así que sumamos la mitad de ancho y altura) - Vec2 char_pos = {.x = current_x + (CHAR_WIDTH_SCALED / 2.0F), .y = position.y + (CHAR_HEIGHT_SCALED / 2.0F)}; - Rendering::renderShape(renderer_, it->second, char_pos, 0.0F, scale, 1.0F, brightness); - - // Avanzar posición - current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; - } else { - // Carácter no soportado: saltar (o renderizar '?' en el futuro) - std::cerr << "[VectorText] Warning: caràcter no suportat '" << c << "'" - << '\n'; - current_x += CHAR_WIDTH_SCALED + SPACING_SCALED; - } - } -} - -void VectorText::renderCentered(const std::string& text, const Vec2& centre_punt, float scale, float spacing, float brightness) const { - // Calcular dimensions del text - float text_width = getTextWidth(text, scale, spacing); - float text_height = getTextHeight(scale); - - // Calcular posición de l'esquina superior izquierda - // restant la meitat de las dimensions del point central - Vec2 posicio_esquerra = { - .x = centre_punt.x - (text_width / 2.0F), - .y = centre_punt.y - (text_height / 2.0F)}; - - // Delegar al método render() existent - render(text, posicio_esquerra, scale, spacing, brightness); -} - -auto VectorText::getTextWidth(const std::string& text, float scale, float spacing) -> float { - if (text.empty()) { - return 0.0F; + // Ancho total = todos los caracteres VISUALES + spacing entre ellos + return (visual_chars * CHAR_WIDTH_SCALED) + ((visual_chars - 1) * SPACING_SCALED); } - const float CHAR_WIDTH_SCALED = BASE_CHAR_WIDTH * scale; - const float SPACING_SCALED = spacing * scale; - - // Contar caracteres visuals (no bytes) - manejar UTF-8 - size_t visual_chars = 0; - for (size_t i = 0; i < text.length(); i++) { - auto c = static_cast(text[i]); - - // Detectar copyright UTF-8 (0xC2 0xA9) - igual que render() - if (c == 0xC2 && i + 1 < text.length() && - static_cast(text[i + 1]) == 0xA9) { - visual_chars++; // Un caràcter visual (©) - i++; // Saltar el següent byte - } else { - visual_chars++; // Caràcter normal - } + auto VectorText::getTextHeight(float scale) -> float { + return BASE_CHAR_HEIGHT * scale; } - // Ancho total = todos los caracteres VISUALES + spacing entre ellos - return (visual_chars * CHAR_WIDTH_SCALED) + ((visual_chars - 1) * SPACING_SCALED); -} - -auto VectorText::getTextHeight(float scale) -> float { - return BASE_CHAR_HEIGHT * scale; -} - } // namespace Graphics diff --git a/source/core/graphics/vector_text.hpp b/source/core/graphics/vector_text.hpp index 07b02d4..f96a480 100644 --- a/source/core/graphics/vector_text.hpp +++ b/source/core/graphics/vector_text.hpp @@ -3,8 +3,6 @@ #pragma once -#include "core/rendering/render_context.hpp" - #include #include @@ -12,12 +10,13 @@ #include #include "core/graphics/shape.hpp" +#include "core/rendering/render_context.hpp" #include "core/types.hpp" namespace Graphics { -class VectorText { - public: + class VectorText { + public: explicit VectorText(Rendering::Renderer* renderer); // Renderizar string completo @@ -27,7 +26,8 @@ class VectorText { // - scale: factor de scale (1.0 = 20×40 px por carácter) // - spacing: espacio entre caracteres en píxeles (a scale 1.0) // - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness) - void render(const std::string& text, const Vec2& position, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F) const; + // - color: color RGBA explícit; si alpha==0 (default) s'usa l'oscil·lador global + void render(const std::string& text, const Vec2& position, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const; // Renderizar string centrado en un punto // - text: cadena a renderizar @@ -35,7 +35,8 @@ class VectorText { // - scale: factor de scale (1.0 = 20×40 px por carácter) // - spacing: espacio entre caracteres en píxeles (a scale 1.0) // - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness) - void renderCentered(const std::string& text, const Vec2& centre_punt, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F) const; + // - color: color RGBA explícit; si alpha==0 (default) s'usa l'oscil·lador global + void renderCentered(const std::string& text, const Vec2& centre_punt, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const; // Calcular ancho total de un string (útil para centrado). // Es estático: no depende del estado del VectorText (el ancho viene de @@ -48,12 +49,12 @@ class VectorText { // Verificar si un carácter está soportado [[nodiscard]] auto isSupported(char c) const -> bool; - private: + private: Rendering::Renderer* renderer_; std::unordered_map> chars_; void loadCharset(); [[nodiscard]] static auto getShapeFilename(char c) -> std::string; -}; + }; } // namespace Graphics diff --git a/source/core/rendering/gpu/gpu_frame_renderer.cpp b/source/core/rendering/gpu/gpu_frame_renderer.cpp index 16d9a27..bdf6fc9 100644 --- a/source/core/rendering/gpu/gpu_frame_renderer.cpp +++ b/source/core/rendering/gpu/gpu_frame_renderer.cpp @@ -269,6 +269,31 @@ namespace Rendering::GPU { indices_.push_back(BASE_INDEX + 2); } + void GpuFrameRenderer::pushRect(float x, float y, float w, float h, float r, float g, float b, float a) { + if (w <= 0.0F || h <= 0.0F) { + return; + } + const float X1 = x; + const float Y1 = y; + const float X2 = x + w; + const float Y2 = y + h; + + const auto BASE_INDEX = static_cast(vertices_.size()); + + // edge_dist=0 → el fragment shader dóna alpha plena (no fade). + vertices_.push_back({X1, Y1, r, g, b, a, 0.0F}); + vertices_.push_back({X2, Y1, r, g, b, a, 0.0F}); + vertices_.push_back({X1, Y2, r, g, b, a, 0.0F}); + vertices_.push_back({X2, Y2, r, g, b, a, 0.0F}); + + indices_.push_back(BASE_INDEX + 0); + indices_.push_back(BASE_INDEX + 1); + indices_.push_back(BASE_INDEX + 2); + indices_.push_back(BASE_INDEX + 1); + indices_.push_back(BASE_INDEX + 3); + indices_.push_back(BASE_INDEX + 2); + } + void GpuFrameRenderer::flushBatch() { if (vertices_.empty() || indices_.empty()) { return; diff --git a/source/core/rendering/gpu/gpu_frame_renderer.hpp b/source/core/rendering/gpu/gpu_frame_renderer.hpp index 2b2174d..0428e7e 100644 --- a/source/core/rendering/gpu/gpu_frame_renderer.hpp +++ b/source/core/rendering/gpu/gpu_frame_renderer.hpp @@ -74,6 +74,13 @@ namespace Rendering::GPU { // Encola una línea con grosor configurable (px). Color RGBA en [0..1]. void pushLine(float x1, float y1, float x2, float y2, float thickness, float r, float g, float b, float a); + // Encola un rectàngle massís (2 triangles) amb color RGBA [0..1]. Es + // remet pel mateix pipeline de líneas (TRIANGLELIST + alpha blend); els + // vèrtexs es marquen amb edge_dist=0 perquè el fragment shader doni + // alpha completa sense fade geomètric. Útil per a fons semitransparents + // d'UI (notificacions, panels). + void pushRect(float x, float y, float w, float h, float r, float g, float b, float a); + // endFrame: flush del batch de líneas → composite postpro → submit + presenta. void endFrame(); diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 2416d85..afa3eca 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -17,6 +17,7 @@ #include "core/rendering/sdl_manager.hpp" #include "core/resources/resource_helper.hpp" #include "core/resources/resource_loader.hpp" +#include "core/system/notifier.hpp" #include "core/utils/path_utils.hpp" #include "debug_overlay.hpp" #include "game/scenes/game_scene.hpp" @@ -264,6 +265,11 @@ auto Director::run() -> int { // a todas las escenas. Toggle con F11 (visible por defecto en _DEBUG). System::DebugOverlay debug_overlay(sdl.getRenderer(), cfg_->rendering); + // Sistema de notificacions toast: singleton accessible des d'on calgui + // (F1-F5 a sdl_manager, ESC a global_events). El renderer ha de viure + // tant com el Notifier; el destruim explícitament abans de tornar. + System::Notifier::init(sdl.getRenderer()); + // Bucle principal: construir escena → frame loop → destruir → siguiente. while (context.nextScene() != SceneType::EXIT) { SceneManager::actual = context.nextScene(); @@ -275,6 +281,7 @@ auto Director::run() -> int { } SceneManager::actual = SceneType::EXIT; + System::Notifier::destroy(); return 0; } @@ -325,6 +332,9 @@ void Director::runFrameLoop(Scene& scene, SDLManager& sdl, SceneContext& context scene.update(delta_time); debug_overlay.update(delta_time); + if (auto* notifier = System::Notifier::get(); notifier != nullptr) { + notifier->update(delta_time); + } Audio::update(); // Si la swapchain no está disponible (ventana minimizada, etc.), @@ -335,7 +345,10 @@ void Director::runFrameLoop(Scene& scene, SDLManager& sdl, SceneContext& context } sdl.updateRenderingContext(); scene.draw(); - debug_overlay.draw(); // siempre on top de la escena + debug_overlay.draw(); // sempre per damunt de l'escena + if (const auto* notifier = System::Notifier::get(); notifier != nullptr) { + notifier->draw(); // toast: per damunt de tot + } sdl.present(); } } diff --git a/source/core/system/notifier.cpp b/source/core/system/notifier.cpp new file mode 100644 index 0000000..993b281 --- /dev/null +++ b/source/core/system/notifier.cpp @@ -0,0 +1,186 @@ +// notifier.cpp - Implementació del singleton de notificacions toast + +#include "core/system/notifier.hpp" + +#include "core/rendering/gpu/gpu_frame_renderer.hpp" +#include "core/utils/easing.hpp" + +namespace System { + + namespace { + // Geometria del cuadre en coordenades lògiques (1280×720). + constexpr float CANVAS_WIDTH = 1280.0F; + constexpr float MARGIN_TOP = 40.0F; + constexpr float PADDING_H = 16.0F; + constexpr float PADDING_V = 10.0F; + constexpr float BORDER_THICKNESS = 2.0F; + constexpr float TEXT_SCALE = 0.4F; + constexpr float TEXT_SPACING = 2.0F; + constexpr float BORDER_BRIGHTNESS = 1.0F; + + // Cinemàtica del slide. + constexpr float SLIDE_DURATION_S = 0.30F; + + // Conversió color SDL → float [0,1]. + constexpr auto toUnit(Uint8 v) -> float { + return static_cast(v) / 255.0F; + } + + // Color del fons: variant fosca del text (0.25× RGB) amb alpha 0.65. + struct UnitRGBA { + float r; + float g; + float b; + float a; + }; + + constexpr auto textColorFloat(SDL_Color c) -> UnitRGBA { + return UnitRGBA{.r = toUnit(c.r), .g = toUnit(c.g), .b = toUnit(c.b), .a = toUnit(c.a)}; + } + + constexpr auto bgColorFloat(SDL_Color c) -> UnitRGBA { + constexpr float DARKEN = 0.25F; + constexpr float BG_ALPHA = 0.65F; + return UnitRGBA{ + .r = toUnit(c.r) * DARKEN, + .g = toUnit(c.g) * DARKEN, + .b = toUnit(c.b) * DARKEN, + .a = BG_ALPHA}; + } + + // Presets per als atajos semàntics. + constexpr SDL_Color COLOR_INFO{.r = 230, .g = 230, .b = 230, .a = 255}; + constexpr SDL_Color COLOR_WARN{.r = 255, .g = 180, .b = 40, .a = 255}; + constexpr SDL_Color COLOR_EXIT{.r = 255, .g = 80, .b = 80, .a = 255}; + constexpr float DURATION_INFO = 2.0F; + constexpr float DURATION_WARN = 3.0F; + constexpr float DURATION_EXIT = 3.0F; + } // namespace + + std::unique_ptr Notifier::instance; + + void Notifier::init(Rendering::Renderer* renderer) { + if (!instance) { + instance = std::unique_ptr(new Notifier(renderer)); + } + } + + void Notifier::destroy() { instance.reset(); } + + auto Notifier::get() -> Notifier* { return instance.get(); } + + Notifier::Notifier(Rendering::Renderer* renderer) + : renderer_(renderer), + text_(renderer) {} + + void Notifier::notify(const std::string& text, SDL_Color text_color, float duration_s) { + current_text_ = text; + current_color_ = text_color; + hold_remaining_s_ = duration_s; + + const float TEXT_W = Graphics::VectorText::getTextWidth(text, TEXT_SCALE, TEXT_SPACING); + const float TEXT_H = Graphics::VectorText::getTextHeight(TEXT_SCALE); + + box_w_ = TEXT_W + (PADDING_H * 2.0F); + box_h_ = TEXT_H + (PADDING_V * 2.0F); + text_x_ = (CANVAS_WIDTH - TEXT_W) * 0.5F; + + y_on_ = MARGIN_TOP; + y_off_ = -(box_h_ + BORDER_THICKNESS); + + // Si ja es veu, reseteja el slide-in des de la posició actual perquè + // la transició sembli continua. Si està amagat, arrenc des de fora. + if (status_ == Status::HIDDEN) { + y_current_ = y_off_; + } + status_ = Status::ENTERING; + slide_elapsed_s_ = 0.0F; + text_scale_ = TEXT_SCALE; + } + + void Notifier::notifyInfo(const std::string& text) { notify(text, COLOR_INFO, DURATION_INFO); } + void Notifier::notifyWarn(const std::string& text) { notify(text, COLOR_WARN, DURATION_WARN); } + void Notifier::notifyExit(const std::string& text) { notify(text, COLOR_EXIT, DURATION_EXIT); } + + void Notifier::update(float delta_time) { + switch (status_) { + case Status::ENTERING: { + slide_elapsed_s_ += delta_time; + if (slide_elapsed_s_ >= SLIDE_DURATION_S) { + y_current_ = y_on_; + status_ = Status::HOLDING; + slide_elapsed_s_ = 0.0F; + } else { + const float T = slide_elapsed_s_ / SLIDE_DURATION_S; + const float K = Utils::Easing::outCubic(T); + y_current_ = y_off_ + ((y_on_ - y_off_) * K); + } + break; + } + case Status::HOLDING: { + hold_remaining_s_ -= delta_time; + if (hold_remaining_s_ <= 0.0F) { + status_ = Status::EXITING; + slide_elapsed_s_ = 0.0F; + } + break; + } + case Status::EXITING: { + slide_elapsed_s_ += delta_time; + if (slide_elapsed_s_ >= SLIDE_DURATION_S) { + y_current_ = y_off_; + status_ = Status::HIDDEN; + } else { + const float T = slide_elapsed_s_ / SLIDE_DURATION_S; + const float K = Utils::Easing::inCubic(T); + y_current_ = y_on_ + ((y_off_ - y_on_) * K); + } + break; + } + case Status::HIDDEN: + default: + break; + } + } + + void Notifier::draw() const { + if (status_ == Status::HIDDEN) { + return; + } + + const float BOX_X = (CANVAS_WIDTH - box_w_) * 0.5F; + const float BOX_Y = y_current_; + const UnitRGBA TC = textColorFloat(current_color_); + const UnitRGBA BG = bgColorFloat(current_color_); + + auto* gpu = renderer_; + + // 1. Fons semitransparent. + gpu->pushRect(BOX_X, BOX_Y, box_w_, box_h_, BG.r, BG.g, BG.b, BG.a); + + // 2. Bordes (4 línies amb el color del text). + const float X1 = BOX_X; + const float Y1 = BOX_Y; + const float X2 = BOX_X + box_w_; + const float Y2 = BOX_Y + box_h_; + gpu->pushLine(X1, Y1, X2, Y1, BORDER_THICKNESS, TC.r, TC.g, TC.b, TC.a); // top + gpu->pushLine(X1, Y2, X2, Y2, BORDER_THICKNESS, TC.r, TC.g, TC.b, TC.a); // bottom + gpu->pushLine(X1, Y1, X1, Y2, BORDER_THICKNESS, TC.r, TC.g, TC.b, TC.a); // left + gpu->pushLine(X2, Y1, X2, Y2, BORDER_THICKNESS, TC.r, TC.g, TC.b, TC.a); // right + + // 3. Text centrat dins la caixa, amb color explícit (l'alpha != 0 + // li diu al renderShape que no agafe l'oscil·lador global de color). + const float TEXT_Y = BOX_Y + PADDING_V; + text_.render(current_text_, + Vec2{.x = text_x_, .y = TEXT_Y}, + text_scale_, + TEXT_SPACING, + BORDER_BRIGHTNESS, + current_color_); + } + + auto Notifier::isActiveWindow() const -> bool { + return status_ == Status::ENTERING || status_ == Status::HOLDING; + } + +} // namespace System diff --git a/source/core/system/notifier.hpp b/source/core/system/notifier.hpp new file mode 100644 index 0000000..6bd3cda --- /dev/null +++ b/source/core/system/notifier.hpp @@ -0,0 +1,81 @@ +// notifier.hpp - Sistema de notificacions toast (singleton) +// © 2026 JailDesigner +// +// Mostra missatges curts en un cuadre centrat horitzontalment al centre +// superior de la pantalla. El cuadre entra des de fora amb easing outCubic, +// aguanta el temps demanat i surt amb inCubic. El color del text és +// configurable; el fondo es deriva oscurint el RGB del text i posant alpha +// 0.65 (semitransparent). +// +// API singleton (mateix patró que Audio i Input): Notifier::init() al startup, +// Notifier::get()->notify(...) des d'on calgui, Notifier::destroy() al teardown. + +#pragma once + +#include + +#include +#include +#include + +#include "core/graphics/vector_text.hpp" +#include "core/rendering/render_context.hpp" + +namespace System { + + class Notifier { + public: + // Inicialitza el singleton amb el renderer global. El renderer ha de + // viure tant com el Notifier (és del SDLManager, propietat del Director). + static void init(Rendering::Renderer* renderer); + static void destroy(); + [[nodiscard]] static auto get() -> Notifier*; + + // Mostra una notificació. Si ja n'hi ha una visible, es sobreescriu + // (reset a l'estat ENTERING des de la Y actual; mai s'apilen). + // - text: cadena a mostrar (sense salts de línia) + // - text_color: color RGBA del text i del borde + // - duration_s: temps que es queda visible (sense comptar entry/exit) + void notify(const std::string& text, SDL_Color text_color, float duration_s); + + // Atajos semàntics amb colors i durada predefinits. + void notifyInfo(const std::string& text); // blanc, 2.0s + void notifyWarn(const std::string& text); // àmbar, 3.0s + void notifyExit(const std::string& text); // vermell, EXIT_WINDOW_S + + void update(float delta_time); + void draw() const; + + // Activa mentre el toast està entrant o aguantant. Quan està sortint + // o ja amagat, retorna false. Útil per a la lògica de doble-pulsació + // d'ESC: la segona pulsació només confirma sortida si encara aguanta. + [[nodiscard]] auto isActiveWindow() const -> bool; + + private: + explicit Notifier(Rendering::Renderer* renderer); + + enum class Status : std::uint8_t { HIDDEN, + ENTERING, + HOLDING, + EXITING }; + + Rendering::Renderer* renderer_; + Graphics::VectorText text_; + + Status status_{Status::HIDDEN}; + std::string current_text_; + SDL_Color current_color_{.r = 255, .g = 255, .b = 255, .a = 255}; + float hold_remaining_s_{0.0F}; + float slide_elapsed_s_{0.0F}; + float y_current_{0.0F}; + float y_off_{0.0F}; // posició Y fora de pantalla + float y_on_{0.0F}; // posició Y de descans (visible) + float box_w_{0.0F}; + float box_h_{0.0F}; + float text_x_{0.0F}; // X esquerre del text dins la caixa + float text_scale_{0.4F}; + + static std::unique_ptr instance; + }; + +} // namespace System diff --git a/source/core/utils/easing.hpp b/source/core/utils/easing.hpp new file mode 100644 index 0000000..afd45f6 --- /dev/null +++ b/source/core/utils/easing.hpp @@ -0,0 +1,24 @@ +// easing.hpp - Funciones d'interpolació suaus (header-only) +// © 2026 JailDesigner +// +// Conjunt mínim de funcions easing per a animacions d'UI. Totes prenen un +// paràmetre normalitzat t ∈ [0,1] i retornen un valor ∈ [0,1]. + +#pragma once + +namespace Utils::Easing { + + // outCubic: ràpid al principi, suau cap al final. Útil per a entrades de + // notificacions (slide-in: arrenc d'impacte i frenada cuidada). + constexpr auto outCubic(float t) -> float { + const float INV = 1.0F - t; + return 1.0F - (INV * INV * INV); + } + + // inCubic: arranca suau, accelera cap al final. Útil per a sortides + // (slide-out: comença discretament i desapareix ràpid). + constexpr auto inCubic(float t) -> float { + return t * t * t; + } + +} // namespace Utils::Easing