From fb40d0effb082713923d60ef8eb851a1bd23f35d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 19:05:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(menu):=20submen=C3=BA=20Controls=20i=20blo?= =?UTF-8?q?queig=20de=20polling=20amb=20men=C3=BA=20obert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + data/locale/ca.yaml | 13 ++++ data/locale/en.yaml | 13 ++++ source/core/input/input_state.cpp | 31 +++++++++ source/core/input/input_state.hpp | 46 +++++++++++++ source/game/entities/player.cpp | 7 +- source/game/ui/service_menu.cpp | 106 ++++++++++++++++++++++++++++-- source/game/ui/service_menu.hpp | 19 ++++++ 8 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 source/core/input/input_state.cpp create mode 100644 source/core/input/input_state.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4769ac5..8bec297 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ set(APP_SOURCES source/core/locale/locale.cpp source/core/input/key_config.cpp source/core/input/global_inputs.cpp + source/core/input/input_state.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 135c9e5..0ea2131 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -26,11 +26,24 @@ menu: root: "Menú de servei" video: "Vídeo" audio: "Àudio" + controls: "Controls" + keyboard: "Teclat" + gamepad: "Comandament" system: "Sistema" + labels: + player_1: "Jugador 1" + gamepad_unavailable: "No disponible" items: video_submenu: "Vídeo" audio_submenu: "Àudio" + controls_submenu: "Controls" + keyboard_submenu: "Teclat" + gamepad_submenu: "Comandament" system_submenu: "Sistema" + move_up: "Amunt" + move_down: "Avall" + move_left: "Esquerra" + move_right: "Dreta" zoom: "Zoom" screen: "Pantalla completa" vsync: "VSync" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 5babd3d..9636f3a 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -26,11 +26,24 @@ menu: root: "Service menu" video: "Video" audio: "Audio" + controls: "Controls" + keyboard: "Keyboard" + gamepad: "Gamepad" system: "System" + labels: + player_1: "Player 1" + gamepad_unavailable: "Not available" items: video_submenu: "Video" audio_submenu: "Audio" + controls_submenu: "Controls" + keyboard_submenu: "Keyboard" + gamepad_submenu: "Gamepad" system_submenu: "System" + move_up: "Up" + move_down: "Down" + move_left: "Left" + move_right: "Right" zoom: "Zoom" screen: "Fullscreen" vsync: "VSync" diff --git a/source/core/input/input_state.cpp b/source/core/input/input_state.cpp new file mode 100644 index 0000000..fab5ecd --- /dev/null +++ b/source/core/input/input_state.cpp @@ -0,0 +1,31 @@ +#include "core/input/input_state.hpp" + +#include + +namespace InputState { + + namespace { + + bool blocked = false; + + // Buffer retornat quan el bloqueig és actiu. `SDL_GetKeyboardState` + // retorna un pointer d'una mida igual a `SDL_SCANCODE_COUNT`; el + // nostre buffer ha de ser com a mínim igual de gran perquè els + // consumidors puguin indexar-lo amb qualsevol scancode sense + // llegir fora de rang. + auto zeroKeyboard() -> const bool* { + static const std::array ZEROS{}; + return ZEROS.data(); + } + + } // namespace + + void setBlocked(bool value) { blocked = value; } + + auto isBlocked() -> bool { return blocked; } + + auto getKeyboardState() -> const bool* { + return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr); + } + +} // namespace InputState diff --git a/source/core/input/input_state.hpp b/source/core/input/input_state.hpp new file mode 100644 index 0000000..9f55b44 --- /dev/null +++ b/source/core/input/input_state.hpp @@ -0,0 +1,46 @@ +#pragma once + +// Wrapper de l'estat d'input (polling) amb gating de bloqueig. +// +// El service menu (i qualsevol UI modal futura) ha de poder "amagar" l'estat +// d'entrada als consumidors de polling — el jugador, entitats controlades +// per l'usuari, etc. — mentre la UI captura l'atenció, sense interrompre la +// resta del joc. Per això tots els consumidors de polling han de llegir a +// través d'aquest mòdul i no cridar `SDL_GetKeyboardState` directament. Si +// `setBlocked(true)` està actiu, el wrapper retorna un buffer tot-a-zero; +// altrament, la crida és transparent (mateix pointer que SDL). +// +// El menú NO passa per aquí: consumeix events (`SDL_EVENT_KEY_DOWN`) abans +// 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. + +#include + +namespace InputState { + + // Activa/desactiva el bloqueig global de polling. El ServiceMenu el + // crida a `toggle()`/`close()`. No es toca des de cap altre lloc. + void setBlocked(bool value); + + [[nodiscard]] auto isBlocked() -> bool; + + // Equivalent a `SDL_GetKeyboardState(nullptr)`: retorna un buffer de + // `SDL_SCANCODE_COUNT` bools. Si l'input està bloquejat, tots zero — + // el consumidor veu com si cap tecla estigués premuda, sense branques + // explícites. El buffer és estable mentre dura la sessió; no cal + // guardar-lo frame a frame. + [[nodiscard]] auto getKeyboardState() -> const bool*; + +} // namespace InputState diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index d06fe35..e6edd1b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -8,6 +8,7 @@ #include #include +#include "core/input/input_state.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" @@ -57,7 +58,11 @@ Player::Player(const std::string& anim_key, float x, float y, const Controls& co } void Player::update(float delta_time) { - const bool* keys = SDL_GetKeyboardState(nullptr); + // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el + // service menu (o qualsevol UI modal) està obert, el wrapper retorna + // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari + // navega el menú. Polling-blocked; veure `core/input/input_state.hpp`. + const bool* keys = InputState::getKeyboardState(); float vx = 0.0F; float vy = 0.0F; if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; } diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 78ef6cd..9656ecd 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -7,6 +7,7 @@ #include #include "core/audio/audio.hpp" +#include "core/input/input_state.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" #include "core/rendering/canvas.hpp" @@ -33,6 +34,13 @@ namespace { constexpr int PAD_X = 10; constexpr int GAP_Y = 6; constexpr int ITEM_GAP_Y = 2; + // Les fonts display reserven ~2 px de descendent al fons de la seua + // caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests + // glifs, eixos 2 px queden buits i el gap visual sota el títol sembla + // més gran que el del damunt. Compensem-ho restant-los al càlcul + // d'alçada i al posicionament del separador; així el títol queda + // òpticament centrat entre la vora superior i la línia divisòria. + constexpr int TITLE_DESCENDER_TRIM = 2; constexpr int MIN_ZOOM = 1; constexpr int MAX_ZOOM = 6; @@ -188,6 +196,22 @@ auto ServiceMenu::makeLabel(std::string text, std::function visible) -> return item; } +auto ServiceMenu::makeKeyBind(std::string label_key, + std::function getter, + std::function setter) -> Item { + Item item; + item.kind = Item::Kind::KEY_BIND; + item.label_key = std::move(label_key); + item.get_scancode = std::move(getter); + item.set_scancode = std::move(setter); + // Si no hi ha setter, l'item queda purament informatiu: no pot capturar + // una tecla nova. Així la primera iteració de la pàgina Controls pot + // mostrar les vinculacions actuals sense tenir encara la infraestructura + // de mutació a `Options::`. + item.selectable = static_cast(item.set_scancode); + return item; +} + // --- Cicle de vida --- auto ServiceMenu::get() -> ServiceMenu& { @@ -212,6 +236,11 @@ void ServiceMenu::toggle() { open_ = true; closing_ = false; animated_h_ = 0.0F; // Comença des de 0 perquè la caixa crega. + // Bloqueja l'input de polling: els consumidors com `Player` veuran + // l'estat del teclat tot-a-zero mentre el menú sigui obert. Els + // events (KEY_DOWN, etc.) ja els consumeix `processMenuInput`, + // així que la navegació del menú no es veu afectada. + InputState::setBlocked(true); } } @@ -219,6 +248,9 @@ void ServiceMenu::close() { if (!open_) { return; } open_ = false; closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura. + // Allibera el polling de seguida — la closing animation és purament + // visual i no ha d'impedir que el jugador es mogui un cop ha tancat. + InputState::setBlocked(false); } // `isVisible` inclou l'animació de tancament: retorna true mentre open_anim_>0 @@ -381,6 +413,12 @@ void ServiceMenu::activateCurrent(int direction) { case Item::Kind::ACTION: if (direction > 0 && item.action) { item.action(); } break; + case Item::Kind::KEY_BIND: + // TODO: entrar en mode captura quan hi hagi `set_scancode`. + // De moment no fem res — els items sense setter ja no són + // seleccionables (veure `makeKeyBind`), per tant aquest case + // només es trepitja si algú en fa un amb setter en el futur. + break; } } @@ -432,6 +470,8 @@ auto ServiceMenu::buildRoot() -> Page { []() { ServiceMenu::get().pushPage(buildVideo()); })); page.items.push_back(makeSubmenu("menu.items.audio_submenu", []() { ServiceMenu::get().pushPage(buildAudio()); })); + page.items.push_back(makeSubmenu("menu.items.controls_submenu", + []() { ServiceMenu::get().pushPage(buildControls()); })); page.items.push_back(makeSubmenu("menu.items.system_submenu", []() { ServiceMenu::get().pushPage(buildSystem()); })); return page; @@ -639,6 +679,55 @@ auto ServiceMenu::buildAudio() -> Page { return page; } +auto ServiceMenu::buildControls() -> Page { + Page page; + page.title_key = "menu.titles.controls"; + page.items.push_back(makeSubmenu("menu.items.keyboard_submenu", + []() { ServiceMenu::get().pushPage(buildKeyboardControls()); })); + // Comandament: la pàgina existeix però encara no té infraestructura + // d'integració (no detectem gamepads ni mapegem botons). Entra-hi per + // veure un placeholder fins que la lògica arribi. + page.items.push_back(makeSubmenu("menu.items.gamepad_submenu", + []() { ServiceMenu::get().pushPage(buildGamepadControls()); })); + return page; +} + +auto ServiceMenu::buildKeyboardControls() -> Page { + Page page; + page.title_key = "menu.titles.keyboard"; + + // Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2, + // només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles + // per sota, sense tocar cap altra cosa. + page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1"))); + + // Vinculacions actuals: les llegim directament de `Defaults::KeysGame`. + // Encara no hi ha setter, per tant els items surten com a informatius + // (no seleccionables). La lògica de captura i persistència arribarà en + // una segona iteració amb `Options::keysGameP1()`. + page.items.push_back(makeKeyBind("menu.items.move_up", + []() { return Defaults::KeysGame::UP; })); + page.items.push_back(makeKeyBind("menu.items.move_down", + []() { return Defaults::KeysGame::DOWN; })); + page.items.push_back(makeKeyBind("menu.items.move_left", + []() { return Defaults::KeysGame::LEFT; })); + page.items.push_back(makeKeyBind("menu.items.move_right", + []() { return Defaults::KeysGame::RIGHT; })); + + return page; +} + +auto ServiceMenu::buildGamepadControls() -> Page { + Page page; + page.title_key = "menu.titles.gamepad"; + page.center_items = true; + // Placeholder: un sol LABEL que indica que la pàgina encara no té + // contingut real. Quan s'afegeixi el suport de gamepad, aquesta + // pàgina es substituirà pel llistat real de botons mapejables. + page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable"))); + return page; +} + auto ServiceMenu::buildSystem() -> Page { Page page; page.title_key = "menu.titles.system"; @@ -690,6 +779,8 @@ auto ServiceMenu::formatValue(const Item& item) -> std::string { return Locale::get().text("menu.values.submenu_arrow"); case Item::Kind::ACTION: return {}; + case Item::Kind::KEY_BIND: + return item.get_scancode ? SDL_GetScancodeName(item.get_scancode()) : std::string{}; } return {}; } @@ -703,10 +794,10 @@ auto ServiceMenu::targetBoxHeight(const Page& page) const -> int { // Layout: GAP_Y enmig dels blocs estructurals (top, títol↔separador, // separador↔primer item, últim item↔bottom). Entre items consecutius, // ITEM_GAP_Y (més petit, perquè els items no queden massa airejats). - int h = GAP_Y; // marge superior - h += TITLE_H + GAP_Y; // títol + gap estructural + int h = GAP_Y; // marge superior + h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // títol + gap (compensant descendent) if (!page.subtitle.empty()) { - h += TITLE_H + GAP_Y; // subtítol + gap estructural + h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // subtítol idem } h += 1 + GAP_Y; // separador d'1 px + gap estructural if (VISIBLE_ITEMS > 0) { @@ -752,7 +843,7 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) Locale::get().text(page.title_key), TITLE_KERNING, UiColor::MENU_TITLE); - cursor_y += TITLE_H + GAP_Y; + cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; if (!page.subtitle.empty()) { font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, @@ -761,7 +852,7 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) page.subtitle, TITLE_KERNING, UiColor::MENU_TITLE); - cursor_y += TITLE_H + GAP_Y; + cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; } Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiColor::MENU_BORDER]); @@ -783,7 +874,10 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) Uint8 label_color = UiColor::MENU_LABEL; if (item.kind == Item::Kind::LABEL) { - label_color = UiColor::MENU_DISABLED; // to gris per diferenciar-lo dels botons actius + // Mateix to que el títol de la pàgina: les LABELs actuen com + // a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a + // Sistema) i han de tenir la mateixa presència visual. + label_color = UiColor::MENU_TITLE; } else if (SELECTED) { label_color = UiColor::MENU_CURSOR; } diff --git a/source/game/ui/service_menu.hpp b/source/game/ui/service_menu.hpp index 47f723e..041490d 100644 --- a/source/game/ui/service_menu.hpp +++ b/source/game/ui/service_menu.hpp @@ -72,6 +72,7 @@ class ServiceMenu { INT_RANGE, // L/R descent/ascent per step, clamp a [min,max] SUBMENU, // RETURN/R: push la pàgina construïda per `pushSub` ACTION, // RETURN: dispara `action` + KEY_BIND, // Mostra el nom d'una tecla; RETURN captura-ne de nova. }; Kind kind{Kind::TOGGLE}; // Per a tots els Kind menys LABEL, `label_key` és una clau de Locale. @@ -97,6 +98,12 @@ class ServiceMenu { std::function push_sub; std::function action; + // Per a KEY_BIND: getter que retorna l'scancode actual i setter + // opcional que el muta. Si `set_scancode` és buit, l'item es veu + // però la captura queda deshabilitada (placeholder). + std::function get_scancode; + std::function set_scancode; + // Si està buit, l'item sempre és visible. Si retorna false, l'item // es salta tant al render com a la navegació (cursor salta). std::function visible; @@ -166,6 +173,15 @@ class ServiceMenu { std::string text, std::function visible = {}) -> Item; + // Item de vinculació de tecla. Mostra el nom de l'scancode al costat + // dret. Si `setter` és buit, l'item surt com a informatiu (no captura). + // Quan el setter sigui operatiu, RETURN entrarà en mode captura i la + // pròxima tecla que es premi sobreescriurà la vinculació. + [[nodiscard]] static auto makeKeyBind( + std::string label_key, + std::function getter, + std::function setter = {}) -> Item; + // --- Navegació i estat --- void pushPage(Page page); void popPage(); @@ -184,6 +200,9 @@ class ServiceMenu { [[nodiscard]] static auto buildRoot() -> Page; [[nodiscard]] static auto buildVideo() -> Page; [[nodiscard]] static auto buildAudio() -> Page; + [[nodiscard]] static auto buildControls() -> Page; + [[nodiscard]] static auto buildKeyboardControls() -> Page; + [[nodiscard]] static auto buildGamepadControls() -> Page; [[nodiscard]] static auto buildSystem() -> Page; // Helpers privats d'items de vídeo: aïllen la lògica de