feat(menu): submenú Controls i bloqueig de polling amb menú obert
This commit is contained in:
1 parent
350625dec0
commit
fb40d0effb
8 files changed
+229
-7
No files matched your search
@@ -0,0 +1,31 @@
|
||||
#include "core/input/input_state.hpp"
|
||||
|
||||
#include <array>
|
||||
|
||||
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<bool, SDL_SCANCODE_COUNT> 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
|
||||
@@ -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 <SDL3/SDL.h>
|
||||
|
||||
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
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
#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; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <utility>
|
||||
|
||||
#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<bool()> visible) ->
|
||||
return item;
|
||||
}
|
||||
|
||||
auto ServiceMenu::makeKeyBind(std::string label_key,
|
||||
std::function<SDL_Scancode()> getter,
|
||||
std::function<void(SDL_Scancode)> 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<bool>(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;
|
||||
}
|
||||
|
||||
@@ -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<void()> push_sub;
|
||||
std::function<void()> 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<SDL_Scancode()> get_scancode;
|
||||
std::function<void(SDL_Scancode)> 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<bool()> visible;
|
||||
@@ -166,6 +173,15 @@ class ServiceMenu {
|
||||
std::string text,
|
||||
std::function<bool()> 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<SDL_Scancode()> getter,
|
||||
std::function<void(SDL_Scancode)> 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
|
||||
|
||||
Reference in new issue
Block a user