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(); };