From be9309d342fb8794fc1c3fdc56aa23bda9fa2c15 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 19:33:18 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(notifier):=20suport=20multilinia=20amb?= =?UTF-8?q?=20wrap=20autom=C3=A0tic=20per=20espais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/core/rendering/notifier.cpp | 90 +++++++++++++++++++++++++----- source/core/rendering/notifier.hpp | 18 +++++- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/source/core/rendering/notifier.cpp b/source/core/rendering/notifier.cpp index 33e3f4d..3f775a5 100644 --- a/source/core/rendering/notifier.cpp +++ b/source/core/rendering/notifier.cpp @@ -1,11 +1,59 @@ #include "core/rendering/notifier.hpp" +#include +#include #include #include "core/rendering/screen.hpp" #include "core/rendering/text.hpp" #include "utils/defines.hpp" +namespace { + + // Parteix `text` en una llista de línies que caben cadascuna dins + // `max_width`. Algoritme de projecte_2026: tall per espais, la paraula + // que no cap passa a la línia següent. Les paraules aïllades més + // llargues que `max_width` es queden a la seva pròpia línia sense + // trencar-les (cas improbable amb els pretty_names de gamepad retallats + // a ~25 caràcters). + auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector { + std::vector lines; + if (text.empty()) { + lines.emplace_back(); + return lines; + } + + std::string current; + std::size_t i = 0; + while (i < text.size()) { + const std::size_t SPACE_POS = text.find(' ', i); + const bool LAST = (SPACE_POS == std::string::npos); + const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i); + + if (current.empty()) { + current = WORD; + } else { + std::string candidate = current; + candidate += ' '; + candidate += WORD; + if (font.length(candidate, kerning) <= max_width) { + current = std::move(candidate); + } else { + lines.push_back(current); + current = WORD; + } + } + + if (LAST) { break; } + i = SPACE_POS + 1; + } + + lines.push_back(current); + return lines; + } + +} // namespace + auto Notifier::get() -> Notifier& { static Notifier instance_; return instance_; @@ -34,8 +82,8 @@ void Notifier::update(int delta_ms) { break; case Status::VANISHING: y_ -= SLIDE_SPEED * DT; - if (y_ <= static_cast(-HEIGHT)) { - y_ = static_cast(-HEIGHT - MARGIN_TOP); + if (y_ <= static_cast(-current_box_h_)) { + y_ = static_cast(-current_box_h_ - MARGIN_TOP); status_ = Status::HIDDEN; } break; @@ -45,31 +93,45 @@ void Notifier::update(int delta_ms) { } void Notifier::render() { - if (status_ == Status::HIDDEN || text_ == nullptr || current_text_.empty()) { return; } + if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; } - const int TEXT_W = text_->length(current_text_, 1); - const int BOX_W = TEXT_W + (2 * PADDING_H); - const int BOX_X = (GameCanvas::WIDTH - BOX_W) / 2; + const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2; const int BOX_Y = static_cast(y_); // Fons sòlid amb la paleta UI que ens ha injectat el caller a init() // (ARGB directe al canvas, sense tocar cap surface paletada). - Screen::get().fillRect(BOX_X, BOX_Y, BOX_W, HEIGHT, ui_palette_[current_bg_color_]); + Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]); - text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, - GameCanvas::CENTER_X, - BOX_Y + PADDING_V, - current_text_, - 1, - current_text_color_); + const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP; + int line_y = BOX_Y + PADDING_V; + for (const auto& line : current_lines_) { + text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + GameCanvas::CENTER_X, + line_y, + line, + 1, + current_text_color_); + line_y += LINE_STEP; + } } void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms) { // Política simple: la nova notificació reemplaça la que hi havia. Per // gestionar-ne una cua, canviar ací per una FIFO quan calga. - current_text_ = text; + if (text_ == nullptr) { return; } + + current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1); + + // Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding. + const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); }); + current_box_w_ = MAX_LINE_W + (2 * PADDING_H); + + const int N = static_cast(current_lines_.size()); + current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V); + current_text_color_ = text_color; current_bg_color_ = bg_color; remaining_ms_ = time_ms; + y_ = static_cast(-current_box_h_ - MARGIN_TOP); status_ = Status::RISING; } diff --git a/source/core/rendering/notifier.hpp b/source/core/rendering/notifier.hpp index 82b8e0f..9c3acfb 100644 --- a/source/core/rendering/notifier.hpp +++ b/source/core/rendering/notifier.hpp @@ -5,9 +5,11 @@ #include #include #include +#include #include "core/rendering/hud_constants.hpp" #include "core/rendering/surface.hpp" +#include "utils/defines.hpp" class Text; @@ -19,6 +21,10 @@ class Text; // API crua: Notifier::get().notify(text, text_color, bg_color, time_ms). // Per a treballar amb presets pre-definits, vegeu Notification::notify() a // core/rendering/notification_presets.hpp. +// +// Multilinia: si el text passa de l'amplada màxima (`MAX_TEXT_WIDTH`), es +// parteix per espais i la caixa creix en alçada automàticament. Els textos +// curts (cas habitual) ocupen una sola línia i no canvien d'aspecte. class Notifier { public: static auto get() -> Notifier&; @@ -39,9 +45,13 @@ class Notifier { static constexpr int PADDING_H = 6; static constexpr int PADDING_V = 3; - static constexpr int HEIGHT = Rendering::Hud::FONT_SIZE_PX + (2 * PADDING_V); + static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia. static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas. static constexpr float SLIDE_SPEED = 120.0F; + // Amplada màxima (sense comptar padding) que pot ocupar el text abans que + // `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del + // canvas perquè la caixa no toqui els laterals. + static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H); private: enum class Status : std::uint8_t { HIDDEN, @@ -55,8 +65,10 @@ class Notifier { std::shared_ptr text_; Palette ui_palette_{}; Status status_{Status::HIDDEN}; - float y_{static_cast(-HEIGHT - MARGIN_TOP)}; - std::string current_text_; + float y_{0.0F}; + std::vector current_lines_; + int current_box_w_{0}; + int current_box_h_{0}; Uint8 current_text_color_{0}; Uint8 current_bg_color_{0}; int remaining_ms_{0}; From 7c35ffaa74eedd30f2a5cdcee69bf56cd7cf6658 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 19:39:16 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(gamepad):=20detecci=C3=B3=20i=20notifi?= =?UTF-8?q?cacions=20hot-plug=20amb=20GamepadManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + data/locale/ca.yaml | 2 + data/locale/en.yaml | 2 + source/core/input/gamepad_manager.cpp | 164 ++++++++++++++++++++++++++ source/core/input/gamepad_manager.hpp | 77 ++++++++++++ source/core/input/input_state.cpp | 8 ++ source/core/input/input_state.hpp | 22 ++-- source/core/system/director.cpp | 43 ++++++- source/core/system/director.hpp | 1 + 9 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 source/core/input/gamepad_manager.cpp create mode 100644 source/core/input/gamepad_manager.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8bec297..6cedd4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ set(APP_SOURCES source/core/input/key_config.cpp source/core/input/global_inputs.cpp source/core/input/input_state.cpp + source/core/input/gamepad_manager.cpp source/core/rendering/screen.cpp source/core/rendering/canvas.cpp source/core/rendering/notifier.cpp diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 0ea2131..53306fa 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -3,6 +3,8 @@ notification: test: "notificacio de prova" + gamepad_connected: "connectat" + gamepad_disconnected: "desconnectat" input: zoom: "zoom" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 9636f3a..84c0f4c 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -3,6 +3,8 @@ notification: test: "test notification" + gamepad_connected: "connected" + gamepad_disconnected: "disconnected" input: zoom: "zoom" diff --git a/source/core/input/gamepad_manager.cpp b/source/core/input/gamepad_manager.cpp new file mode 100644 index 0000000..fc1bdfc --- /dev/null +++ b/source/core/input/gamepad_manager.cpp @@ -0,0 +1,164 @@ +#include "core/input/gamepad_manager.hpp" + +#include +#include +#include +#include + +#include "core/locale/locale.hpp" +#include "core/rendering/notification_presets.hpp" + +namespace { + +#ifdef __EMSCRIPTEN__ + // A Emscripten, SDL 3.4+ no casa el GUID dels mandos web: Chrome/Android + // no aporten Vendor/Product ID i el parser de SDL extreu dades brutes. + // El W3C Gamepad API, però, garanteix un layout estàndard quan el mapping + // reporta "standard", així que injectem un mapping SDL explícit amb eixe + // layout per al GUID del joystick abans que SDL intenti obrir-lo com a + // gamepad. Fora d'Emscripten és un no-op. + void installWebStandardMapping(SDL_JoystickID jid) { + SDL_GUID guid = SDL_GetJoystickGUIDForID(jid); + char guid_str[33]; + SDL_GUIDToString(guid, guid_str, sizeof(guid_str)); + const char* name = SDL_GetJoystickNameForID(jid); + if (name == nullptr || *name == '\0') { name = "Standard Gamepad"; } + + char mapping[512]; + SDL_snprintf(mapping, sizeof(mapping), + "%s,%s," + "a:b0,b:b1,x:b2,y:b3," + "leftshoulder:b4,rightshoulder:b5," + "lefttrigger:b6,righttrigger:b7," + "back:b8,start:b9," + "leftstick:b10,rightstick:b11," + "dpup:b12,dpdown:b13,dpleft:b14,dpright:b15," + "guide:b16," + "leftx:a0,lefty:a1,rightx:a2,righty:a3," + "platform:Emscripten", + guid_str, + name); + SDL_AddGamepadMapping(mapping); + } +#else + void installWebStandardMapping(SDL_JoystickID /*jid*/) {} +#endif + +} // namespace + +auto GamepadManager::get() -> GamepadManager& { + static GamepadManager instance_; + return instance_; +} + +auto GamepadManager::prettyName(const char* raw) -> std::string { + std::string name = (raw != nullptr && *raw != '\0') ? std::string(raw) : std::string("Gamepad"); + const auto POS = name.find_first_of("(["); + if (POS != std::string::npos) { name.erase(POS); } + while (!name.empty() && name.back() == ' ') { name.pop_back(); } + if (name.size() > 25) { name.resize(25); } + if (name.empty()) { name = "Gamepad"; } + return name; +} + +auto GamepadManager::uniqueName(const std::string& base) const -> std::string { + const bool COLLISION = std::ranges::any_of( + entries_, + [&](const Entry& e) { return e.pretty_name == base; }); + if (!COLLISION) { return base; } + + // Format " #N" començant a #2 — la primera ocurrència manté el nom net. + for (int i = 2; i < 100; ++i) { + std::string candidate = base + " #" + std::to_string(i); + const bool MATCH = std::ranges::any_of( + entries_, + [&](const Entry& e) { return e.pretty_name == candidate; }); + if (!MATCH) { return candidate; } + } + return base; // cas ultra-improbable: 99 mandos idèntics connectats +} + +void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) { + installWebStandardMapping(jid); + if (!SDL_IsGamepad(jid)) { return; } + SDL_Gamepad* pad = SDL_OpenGamepad(jid); + if (pad == nullptr) { + std::cout << "GamepadManager: SDL_OpenGamepad(" << jid + << ") ha fallat: " << SDL_GetError() << '\n'; + return; + } + std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad))); + std::cout << "GamepadManager: connectat " << name << '\n'; + entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name}); + if (notify) { + Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected")); + } +} + +void GamepadManager::removeGamepad(SDL_JoystickID jid) { + auto it = std::ranges::find_if( + entries_, + [jid](const Entry& e) { return e.instance_id == jid; }); + if (it == entries_.end()) { return; } + std::string name = std::move(it->pretty_name); + if (it->pad != nullptr) { SDL_CloseGamepad(it->pad); } + std::cout << "GamepadManager: desconnectat " << name << '\n'; + entries_.erase(it); + Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected")); +} + +void GamepadManager::init() { + if (initialized_) { return; } + if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) { + std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: " + << SDL_GetError() << '\n'; + return; + } + // `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i + // continuem amb les definicions internes del sistema. + const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt"); + if (ADDED > 0) { + std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n"; + } + + int count = 0; + SDL_JoystickID* ids = SDL_GetJoysticks(&count); + if (ids != nullptr) { + for (int i = 0; i < count; ++i) { + addGamepad(ids[i], /*notify=*/false); + } + SDL_free(ids); + } + initialized_ = true; +} + +void GamepadManager::shutdown() { + for (auto& entry : entries_) { + if (entry.pad != nullptr) { SDL_CloseGamepad(entry.pad); } + } + entries_.clear(); + if (initialized_) { + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + initialized_ = false; + } +} + +void GamepadManager::handleEvent(const SDL_Event& ev) { + if (ev.type == SDL_EVENT_GAMEPAD_ADDED) { + addGamepad(ev.gdevice.which, /*notify=*/true); + } else if (ev.type == SDL_EVENT_GAMEPAD_REMOVED) { + removeGamepad(ev.gdevice.which); + } +} + +auto GamepadManager::getPrimary() const -> SDL_Gamepad* { + return entries_.empty() ? nullptr : entries_.front().pad; +} + +auto GamepadManager::getCount() const -> std::size_t { + return entries_.size(); +} + +auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* { + return idx < entries_.size() ? entries_[idx].pad : nullptr; +} diff --git a/source/core/input/gamepad_manager.hpp b/source/core/input/gamepad_manager.hpp new file mode 100644 index 0000000..4081c25 --- /dev/null +++ b/source/core/input/gamepad_manager.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include + +#include +#include +#include + +// Gestor global de gamepads. Descobreix els mandos presents a `init()` +// sense emetre notificacions (el jugador ja els té posats al llançar el +// joc). Durant el runtime, `handleEvent()` processa els events de hot-plug +// i emet notificacions visibles via `Notification::notify`. +// +// El polling real de botons i eixos NO es fa a través d'aquesta classe: +// els consumidors obtenen el `SDL_Gamepad*` primari (o l'enèsim per a +// multi-jugador futur) i el passen a `InputState::getGamepadButton/Axis`, +// que ho filtra pel flag de bloqueig de la UI (mateixa estratègia que +// amb el teclat). +class GamepadManager { + public: + static auto get() -> GamepadManager&; + + GamepadManager(const GamepadManager&) = delete; + GamepadManager(GamepadManager&&) = delete; + auto operator=(const GamepadManager&) -> GamepadManager& = delete; + auto operator=(GamepadManager&&) -> GamepadManager& = delete; + + // Inicialitza el subsistema SDL de gamepad, intenta carregar un + // `gamecontrollerdb.txt` opcional i obri silenciosament tots els + // mandos ja connectats. Cridat una sola vegada des de Director. + void init(); + + // Tanca tots els gamepads oberts i treu el subsistema SDL. Cridat + // des de `~Director` abans de `SDL_Quit` per evitar handles orfes. + void shutdown(); + + // Processa SDL_EVENT_GAMEPAD_ADDED i SDL_EVENT_GAMEPAD_REMOVED + // emesos durant el runtime. Emet notificacions " connectat" o + // " desconnectat". El Director decideix quins events li arriben + // via `processGamepadEvents()`. + void handleEvent(const SDL_Event& ev); + + // Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap. + // Ús típic: Player 1 i navegació del ServiceMenu. + [[nodiscard]] auto getPrimary() const -> SDL_Gamepad*; + + [[nodiscard]] auto getCount() const -> std::size_t; + + // Retorna el mand del jugador `idx`-èsim (0-indexed) o `nullptr` si + // encara no hi ha prou mandos connectats. Reservat per a multi-jugador. + [[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*; + + private: + struct Entry { + SDL_Gamepad* pad{nullptr}; + SDL_JoystickID instance_id{0}; + std::string pretty_name; + }; + + GamepadManager() = default; + ~GamepadManager() = default; + + // Retalla `raw` al primer '(' o '[', elimina espais finals i talla + // a 25 caràcters. Fallback "Gamepad" si queda buit. + [[nodiscard]] static auto prettyName(const char* raw) -> std::string; + + // Si `base` ja existeix al vector, afegeix " #2", " #3", ... fins que + // trobi un nom lliure. Així dos mandos idèntics es distingeixen a les + // notificacions. + [[nodiscard]] auto uniqueName(const std::string& base) const -> std::string; + + void addGamepad(SDL_JoystickID jid, bool notify); + void removeGamepad(SDL_JoystickID jid); + + std::vector entries_; + bool initialized_{false}; +}; diff --git a/source/core/input/input_state.cpp b/source/core/input/input_state.cpp index fab5ecd..05fb6c2 100644 --- a/source/core/input/input_state.cpp +++ b/source/core/input/input_state.cpp @@ -28,4 +28,12 @@ namespace InputState { return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr); } + auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool { + return (blocked || gp == nullptr) ? false : SDL_GetGamepadButton(gp, b); + } + + auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16 { + return (blocked || gp == nullptr) ? static_cast(0) : SDL_GetGamepadAxis(gp, ax); + } + } // namespace InputState diff --git a/source/core/input/input_state.hpp b/source/core/input/input_state.hpp index 9f55b44..a34b932 100644 --- a/source/core/input/input_state.hpp +++ b/source/core/input/input_state.hpp @@ -14,17 +14,9 @@ // que l'escena els vegi, així que la seva navegació segueix funcionant per // molt que el flag estigui posat. // -// --- Nota per a l'extensió a gamepad (pròxima iteració) --- -// Quan s'afegeixi suport de comandament, el jugador haurà de llegir eixos -// i botons via polling (p.ex. `SDL_GetGamepadAxis`/`SDL_GetGamepadButton`). -// Aquest mòdul s'ha d'ampliar amb: -// [[nodiscard]] auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -// -> Sint16; // 0 si bloquejat -// [[nodiscard]] auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -// -> bool; // false si bloquejat -// Mateix patró: zero/false quan `blocked_`, pass-through altrament. Així -// Player (i el que sigui) només ha de canviar el nom de la crida; el menú -// no ha de saber res del gamepad més enllà de processar els seus events. +// El mateix patró aplica al gamepad: `getGamepadButton`/`getGamepadAxis` +// retornen zero/false quan el bloqueig és actiu. Els mandos concrets els +// obtén el caller via `GamepadManager::get().getPrimary()` o similars. #include @@ -43,4 +35,12 @@ namespace InputState { // guardar-lo frame a frame. [[nodiscard]] auto getKeyboardState() -> const bool*; + // Versió filtrada de `SDL_GetGamepadButton`. Retorna false si el bloqueig + // és actiu o si `gp` és nullptr (cas habitual: no hi ha cap mand). + [[nodiscard]] auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool; + + // Versió filtrada de `SDL_GetGamepadAxis`. Retorna 0 si el bloqueig + // és actiu o si `gp` és nullptr. + [[nodiscard]] auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16; + } // namespace InputState diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index c6bba10..3f37515 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -12,6 +12,7 @@ #include #include "core/audio/audio.hpp" +#include "core/input/gamepad_manager.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" #include "core/rendering/notifier.hpp" @@ -102,6 +103,11 @@ Director::Director() { // JA_Music_t que continga, així que no queden streams orfes. Audio::init(Options::buildAudioConfig()); + // Gamepad: descobreix mandos presents silenciosament. A partir d'ara + // els events SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via + // `processGamepadEvents()` i dispararan notificacions. + GamepadManager::get().init(); + // Font compartida pels overlays: construïm una sola instància de Text // i la injectem als consumidors (Notifier, RenderInfo) per a que cap // d'ells hagi de conèixer els noms dels recursos concrets. @@ -200,6 +206,7 @@ Director::~Director() { // desfà les llibreries abans que corren els destructors estàtics. current_scene_.reset(); Resource::Cache::get().clearAudio(); + GamepadManager::get().shutdown(); Audio::destroy(); Screen::get().shutdown(); SDL_Quit(); @@ -235,6 +242,32 @@ void Director::processEvents() { if (QUIT_REQUESTED) { current_scene_.reset(); } } +void Director::processGamepadEvents() { + // Dues responsabilitats: (1) passar ADDED/REMOVED al GamepadManager + // perquè obri/tanqui mandos i emeti les notificacions hot-plug; (2) + // tractar el botó BACK com a toggle del menú quan està tancat (el + // paral·lel de F9 per al teclat). Quan el menú està obert, el BACK + // passa pel pipeline del `ServiceMenu::handleEvent()` via + // `processMenuInput()` i allà es converteix en `close()`. + for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { + bool consumed = false; + if (it->type == SDL_EVENT_GAMEPAD_ADDED || it->type == SDL_EVENT_GAMEPAD_REMOVED) { + GamepadManager::get().handleEvent(*it); + consumed = true; + } else if (it->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && + it->gbutton.button == SDL_GAMEPAD_BUTTON_BACK && + !ServiceMenu::get().isOpen()) { + ServiceMenu::get().toggle(); + consumed = true; + } + if (consumed) { + it = EventBuffer::events.erase(it); + } else { + ++it; + } + } +} + void Director::processMenuInput() { // Intercepta input abans que l'escena vegi els events. Si el menú està // obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8, @@ -263,10 +296,12 @@ void Director::switchSceneIfRequested() { } auto Director::iterate() -> SDL_AppResult { - // Ordre important: primer el menú (pot consumir tecles i INJECTAR un - // SDL_EVENT_QUIT des de l'acció "Eixir del joc"), després processEvents - // perquè vegi el QUIT al mateix frame i tanqui l'escena — altrament el - // QUIT injectat es perdria quan es netegi EventBuffer al final d'iterate. + // Ordre important: primer els events de gamepad (hot-plug + BACK que + // obre el menú quan està tancat), després el menú (pot consumir tecles + // i INJECTAR un SDL_EVENT_QUIT des de l'acció "Eixir del joc"), i + // finalment processEvents perquè vegi el QUIT al mateix frame — altrament + // el QUIT injectat es perdria quan es netegi EventBuffer al final. + processGamepadEvents(); processMenuInput(); processEvents(); diff --git a/source/core/system/director.hpp b/source/core/system/director.hpp index 5414ea0..1d82c8d 100644 --- a/source/core/system/director.hpp +++ b/source/core/system/director.hpp @@ -22,6 +22,7 @@ class Director { void createSystemFolder(); void processEvents(); + static void processGamepadEvents(); static void processMenuInput(); void switchSceneIfRequested(); }; From a230351b79ddb3bd90122fef0e93921eec44f55d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 19:43:37 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(gamepad):=20controls=20de=20gamepad=20?= =?UTF-8?q?per=20al=20player=20i=20el=20men=C3=BA=20de=20servei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.cpp | 32 ++++++++++++++++++++++++++++++ source/game/ui/service_menu.cpp | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index e6edd1b..6cec2bb 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -8,6 +8,7 @@ #include #include +#include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" @@ -20,6 +21,29 @@ namespace { // Alias local per reduir soroll; la constant viu a Defaults::Player. constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S; + // Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767). + // Converteix eix analògic en direcció digital; per sota, es considera + // repòs i no contribueix a la velocitat. + constexpr Sint16 STICK_DEADZONE = 12000; + + // Acumula la contribució del primer gamepad (cruceta + stick esquerre) + // sobre els acumuladors de velocitat. Extracció d'`update()` per a + // mantenir la complexitat cognitiva sota llindar (threshold 25). + void accumulateGamepadMovement(float& vx, float& vy) { + SDL_Gamepad* pad = GamepadManager::get().getPrimary(); + if (pad == nullptr) { return; } + if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; } + if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; } + if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; } + if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; } + const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX); + const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY); + if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; } + if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; } + if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; } + if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; } + } + } // namespace void Player::loadFrom(const std::string& yaml_path) { @@ -70,6 +94,14 @@ void Player::update(float delta_time) { if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; } if (keys[controls_.down]) { vy += SPEED_PX_PER_S; } + // Cruceta i stick esquerre del primer gamepad (si n'hi ha). També + // respecten el bloqueig d'`InputState`, així que amb el menú obert + // els botons tornen false/0 i no contribueixen a la velocitat. + // Direccions oposades (teclat left + d-pad right) es cancel·len de + // forma natural; premuda simultània en la mateixa direcció no duplica + // la velocitat perquè cada branca només resta/suma una vegada. + accumulateGamepadMovement(vx, vy); + setVelX(vx); setVelY(vy); diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 9656ecd..a9973b5 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -426,6 +426,41 @@ void ServiceMenu::activateCurrent(int direction) { auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool { if (!open_) { return false; } + + // Botons del gamepad: cruceta per navegar, EAST/NORTH com a OK, + // WEST/SOUTH com a BACK de pàgina, BACK tanca el menú sencer. Quan + // el menú està obert consumim TOTS els BUTTON_DOWN perquè cap botó + // arribi als consumidors de gameplay. Paral·lel al consum total de + // KEY_DOWN més avall. + if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + switch (ev.gbutton.button) { + case SDL_GAMEPAD_BUTTON_DPAD_UP: + moveCursor(-1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: + moveCursor(+1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: + activateCurrent(-1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: + case SDL_GAMEPAD_BUTTON_EAST: + case SDL_GAMEPAD_BUTTON_NORTH: + activateCurrent(+1); + break; + case SDL_GAMEPAD_BUTTON_WEST: + case SDL_GAMEPAD_BUTTON_SOUTH: + popPage(); + break; + case SDL_GAMEPAD_BUTTON_BACK: + close(); + break; + default: + break; + } + return true; + } + if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } const auto SC = ev.key.scancode;