feat(gamepad): detecció i notificacions hot-plug amb GamepadManager

This commit is contained in:
JailDesigner committed 2026-04-22 19:39:16 +02:00
1 parent be9309d342
commit 7c35ffaa74
9 files changed
+305 -15

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ set(APP_SOURCES
source/core/input/key_config.cpp source/core/input/key_config.cpp
source/core/input/global_inputs.cpp source/core/input/global_inputs.cpp
source/core/input/input_state.cpp source/core/input/input_state.cpp
source/core/input/gamepad_manager.cpp
source/core/rendering/screen.cpp source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp source/core/rendering/canvas.cpp
source/core/rendering/notifier.cpp source/core/rendering/notifier.cpp
+2
View File
@@ -3,6 +3,8 @@
notification: notification:
test: "notificacio de prova" test: "notificacio de prova"
gamepad_connected: "connectat"
gamepad_disconnected: "desconnectat"
input: input:
zoom: "zoom" zoom: "zoom"
+2
View File
@@ -3,6 +3,8 @@
notification: notification:
test: "test notification" test: "test notification"
gamepad_connected: "connected"
gamepad_disconnected: "disconnected"
input: input:
zoom: "zoom" zoom: "zoom"
+164
View File
@@ -0,0 +1,164 @@
#include "core/input/gamepad_manager.hpp"
#include <algorithm>
#include <iostream>
#include <string>
#include <utility>
#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;
}
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <string>
#include <vector>
// 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 "<nom> connectat" o
// "<nom> 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<Entry> entries_;
bool initialized_{false};
};
+8
View File
@@ -28,4 +28,12 @@ namespace InputState {
return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr); 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<Sint16>(0) : SDL_GetGamepadAxis(gp, ax);
}
} // namespace InputState } // namespace InputState
+11 -11
View File
@@ -14,17 +14,9 @@
// que l'escena els vegi, així que la seva navegació segueix funcionant per // que l'escena els vegi, així que la seva navegació segueix funcionant per
// molt que el flag estigui posat. // molt que el flag estigui posat.
// //
// --- Nota per a l'extensió a gamepad (pròxima iteració) --- // El mateix patró aplica al gamepad: `getGamepadButton`/`getGamepadAxis`
// Quan s'afegeixi suport de comandament, el jugador haurà de llegir eixos // retornen zero/false quan el bloqueig és actiu. Els mandos concrets els
// i botons via polling (p.ex. `SDL_GetGamepadAxis`/`SDL_GetGamepadButton`). // obtén el caller via `GamepadManager::get().getPrimary()` o similars.
// 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.
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
@@ -43,4 +35,12 @@ namespace InputState {
// guardar-lo frame a frame. // guardar-lo frame a frame.
[[nodiscard]] auto getKeyboardState() -> const bool*; [[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 } // namespace InputState
+39 -4
View File
@@ -12,6 +12,7 @@
#include <utility> #include <utility>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
@@ -102,6 +103,11 @@ Director::Director() {
// JA_Music_t que continga, així que no queden streams orfes. // JA_Music_t que continga, així que no queden streams orfes.
Audio::init(Options::buildAudioConfig()); 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 // Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap // i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets. // 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. // desfà les llibreries abans que corren els destructors estàtics.
current_scene_.reset(); current_scene_.reset();
Resource::Cache::get().clearAudio(); Resource::Cache::get().clearAudio();
GamepadManager::get().shutdown();
Audio::destroy(); Audio::destroy();
Screen::get().shutdown(); Screen::get().shutdown();
SDL_Quit(); SDL_Quit();
@@ -235,6 +242,32 @@ void Director::processEvents() {
if (QUIT_REQUESTED) { current_scene_.reset(); } 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() { void Director::processMenuInput() {
// Intercepta input abans que l'escena vegi els events. Si el menú està // Intercepta input abans que l'escena vegi els events. Si el menú està
// obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8, // obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8,
@@ -263,10 +296,12 @@ void Director::switchSceneIfRequested() {
} }
auto Director::iterate() -> SDL_AppResult { auto Director::iterate() -> SDL_AppResult {
// Ordre important: primer el menú (pot consumir tecles i INJECTAR un // Ordre important: primer els events de gamepad (hot-plug + BACK que
// SDL_EVENT_QUIT des de l'acció "Eixir del joc"), després processEvents // obre el menú quan està tancat), després el menú (pot consumir tecles
// perquè vegi el QUIT al mateix frame i tanqui l'escena — altrament el // i INJECTAR un SDL_EVENT_QUIT des de l'acció "Eixir del joc"), i
// QUIT injectat es perdria quan es netegi EventBuffer al final d'iterate. // finalment processEvents perquè vegi el QUIT al mateix frame — altrament
// el QUIT injectat es perdria quan es netegi EventBuffer al final.
processGamepadEvents();
processMenuInput(); processMenuInput();
processEvents(); processEvents();
+1
View File
@@ -22,6 +22,7 @@ class Director {
void createSystemFolder(); void createSystemFolder();
void processEvents(); void processEvents();
static void processGamepadEvents();
static void processMenuInput(); static void processMenuInput();
void switchSceneIfRequested(); void switchSceneIfRequested();
}; };