diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 71ccc66..21e1767 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -14,6 +14,8 @@ notification: postfx_on: "POSTPROCESSAT ACTIU" postfx_off: "POSTPROCESSAT INACTIU" locale_switched: "IDIOMA: {lang}" + gamepad_connected: "{name} CONNECTAT" + gamepad_disconnected: "{name} DESCONNECTAT" language: ca: "CATALA" @@ -82,3 +84,29 @@ service_menu: # Valors comuns value_on: "ACTIU" value_off: "INACTIU" + # Items del submenu CONTROLS + controls_pad_p1: "MANDO JUGADOR 1" + controls_pad_p2: "MANDO JUGADOR 2" + controls_no_pad: "SENSE MANDO" + controls_define_keyboard_p1: "REDEFINIR TECLES P1" + controls_define_keyboard_p2: "REDEFINIR TECLES P2" + controls_define_gamepad_p1: "REDEFINIR BOTONS P1" + controls_define_gamepad_p2: "REDEFINIR BOTONS P2" + +# Overlay modal de redefinicio (DefineInputs) +define: + title_keyboard_p1: "REDEFINIR TECLES P1" + title_keyboard_p2: "REDEFINIR TECLES P2" + title_gamepad_p1: "REDEFINIR BOTONS P1" + title_gamepad_p2: "REDEFINIR BOTONS P2" + press_key: "PREMEU UNA TECLA" + press_button: "PREMEU UN BOTO" + complete: "CONFIGURACIO COMPLETA" + no_gamepad: "CAP MANDO ASSIGNAT AL JUGADOR" + action: + left: "ESQUERRA" + right: "DRETA" + fire: "DISPARAR" + accelerate: "ACCELERAR" + start: "START" + menu: "MENU" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index f5fc757..111c936 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -13,6 +13,8 @@ notification: postfx_on: "POSTPROCESS ON" postfx_off: "POSTPROCESS OFF" locale_switched: "LANGUAGE: {lang}" + gamepad_connected: "{name} CONNECTED" + gamepad_disconnected: "{name} DISCONNECTED" language: ca: "CATALAN" @@ -81,3 +83,29 @@ service_menu: # Common values value_on: "ON" value_off: "OFF" + # Items of CONTROLS submenu + controls_pad_p1: "PLAYER 1 GAMEPAD" + controls_pad_p2: "PLAYER 2 GAMEPAD" + controls_no_pad: "NO GAMEPAD" + controls_define_keyboard_p1: "REDEFINE KEYS P1" + controls_define_keyboard_p2: "REDEFINE KEYS P2" + controls_define_gamepad_p1: "REDEFINE BUTTONS P1" + controls_define_gamepad_p2: "REDEFINE BUTTONS P2" + +# Modal overlay for input redefinition (DefineInputs) +define: + title_keyboard_p1: "REDEFINE KEYS P1" + title_keyboard_p2: "REDEFINE KEYS P2" + title_gamepad_p1: "REDEFINE BUTTONS P1" + title_gamepad_p2: "REDEFINE BUTTONS P2" + press_key: "PRESS A KEY" + press_button: "PRESS A BUTTON" + complete: "CONFIGURATION COMPLETE" + no_gamepad: "NO GAMEPAD ASSIGNED TO PLAYER" + action: + left: "LEFT" + right: "RIGHT" + fire: "FIRE" + accelerate: "ACCELERATE" + start: "START" + menu: "MENU" diff --git a/data/shapes/font/char_lparen.shp b/data/shapes/font/char_lparen.shp new file mode 100644 index 0000000..f99f143 --- /dev/null +++ b/data/shapes/font/char_lparen.shp @@ -0,0 +1,9 @@ +# char_lparen.shp - Símbol ( (parèntesi esquerre) +# Dimensions: 20×40 (blocky display) + +name: char_lparen +scale: 1.0 +center: 10, 20 + +# Arc cap a l'esquerra aproximat amb 4 trams rectes +polyline: 14,4 8,12 6,20 8,28 14,36 diff --git a/data/shapes/font/char_rparen.shp b/data/shapes/font/char_rparen.shp new file mode 100644 index 0000000..f10eb95 --- /dev/null +++ b/data/shapes/font/char_rparen.shp @@ -0,0 +1,9 @@ +# char_rparen.shp - Símbol ) (parèntesi dret) +# Dimensions: 20×40 (blocky display) + +name: char_rparen +scale: 1.0 +center: 10, 20 + +# Arc cap a la dreta aproximat amb 4 trams rectes +polyline: 6,4 12,12 14,20 12,28 6,36 diff --git a/data/shapes/font/char_slash.shp b/data/shapes/font/char_slash.shp new file mode 100644 index 0000000..399fcc7 --- /dev/null +++ b/data/shapes/font/char_slash.shp @@ -0,0 +1,9 @@ +# char_slash.shp - Símbol / (barra) +# Dimensions: 20×40 (blocky display) + +name: char_slash +scale: 1.0 +center: 10, 20 + +# Línia diagonal de baix-esquerra a dalt-dreta +line: 4,36 16,4 diff --git a/source/core/config/engine_config.hpp b/source/core/config/engine_config.hpp index a9a3f7d..29680a2 100644 --- a/source/core/config/engine_config.hpp +++ b/source/core/config/engine_config.hpp @@ -48,12 +48,15 @@ namespace Config { int button_right{SDL_GAMEPAD_BUTTON_DPAD_RIGHT}; int button_thrust{SDL_GAMEPAD_BUTTON_WEST}; // X button int button_shoot{SDL_GAMEPAD_BUTTON_SOUTH}; // A button + int button_start{SDL_GAMEPAD_BUTTON_START}; // Start button + int button_menu{SDL_GAMEPAD_BUTTON_BACK}; // Select/Back -> obre menu servei }; struct PlayerBindings { KeyboardBindings keyboard{}; GamepadBindings gamepad{}; std::string gamepad_name; // Empty = auto-assign by index + std::string gamepad_path; // Prioritari sobre name per distingir mateixos models }; struct AudioConfig { diff --git a/source/core/graphics/vector_text.cpp b/source/core/graphics/vector_text.cpp index 2e30810..c916980 100644 --- a/source/core/graphics/vector_text.cpp +++ b/source/core/graphics/vector_text.cpp @@ -47,7 +47,7 @@ namespace Graphics { } // Cargar símbolos - const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?"}; + const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?", "/", "(", ")"}; for (const auto& sym : SYMBOLS) { char c = sym[0]; std::string filename = getShapeFilename(c); @@ -164,6 +164,12 @@ namespace Graphics { return "font/char_exclamation.shp"; case '?': return "font/char_question.shp"; + case '/': + return "font/char_slash.shp"; + case '(': + return "font/char_lparen.shp"; + case ')': + return "font/char_rparen.shp"; case ' ': return ""; // Espai es maneja sin load shape diff --git a/source/core/graphics/vector_text.hpp b/source/core/graphics/vector_text.hpp index 864390e..137cade 100644 --- a/source/core/graphics/vector_text.hpp +++ b/source/core/graphics/vector_text.hpp @@ -21,7 +21,7 @@ namespace Graphics { // Renderizar string completo // - text: cadena a renderizar (soporta: A-Z, a-z, 0-9, '.', ',', '-', ':', - // '!', '?', ' ') + // '!', '?', '/', '(', ')', ' ') // - position: posición inicial (esquina superior izquierda) // - scale: factor de scale (1.0 = 20×40 px por carácter) // - spacing: espacio entre caracteres en píxeles (a scale 1.0) diff --git a/source/core/input/define_inputs.cpp b/source/core/input/define_inputs.cpp new file mode 100644 index 0000000..f1f8350 --- /dev/null +++ b/source/core/input/define_inputs.cpp @@ -0,0 +1,405 @@ +// define_inputs.cpp - Implementacio de l'overlay modal de redefinicio +// © 2026 JailDesigner + +#include "core/input/define_inputs.hpp" + +#include +#include +#include +#include + +#include "core/audio/audio.hpp" +#include "core/defaults/service_menu.hpp" +#include "core/input/input.hpp" +#include "core/locale/locale.hpp" +#include "core/types.hpp" +#include "game/config_yaml.hpp" + +namespace { + + constexpr float CANVAS_W = 1280.0F; + constexpr float CANVAS_H = 720.0F; + + // Llindar de trigger per a edge-detect L2/R2 com a boto virtual. + constexpr Sint16 TRIGGER_THRESHOLD = 16384; + + // Codis virtuals per als triggers (consistents amb input_types.cpp). + constexpr int TRIGGER_L2_VIRTUAL = 100; + constexpr int TRIGGER_R2_VIRTUAL = 101; + + // Durada del missatge de confirmacio abans de tancar-se. + constexpr float COMPLETE_DISPLAY_S = 1.5F; + + // Llindar dpad als axis sticks: no es captura per evitar conflicte amb el + // moviment LEFT/RIGHT/UP/DOWN (que es presuposen no redefinibles al mando). + constexpr Sint16 STICK_THRESHOLD = 16384; + + // Crida pushRect amb un SDL_Color (les components s'escalen a [0..1]). + void fillRect(Rendering::Renderer* renderer, float x, float y, float w, float h, SDL_Color color) { + renderer->pushRect(x, y, w, h, static_cast(color.r) / 255.0F, static_cast(color.g) / 255.0F, static_cast(color.b) / 255.0F, static_cast(color.a) / 255.0F); + } + + auto titleKey(System::DefineInputs::Mode mode, System::DefineInputs::Player player) -> std::string { + const bool IS_KB = (mode == System::DefineInputs::Mode::KEYBOARD); + const bool IS_P1 = (player == System::DefineInputs::Player::P1); + if (IS_KB && IS_P1) { + return "define.title_keyboard_p1"; + } + if (IS_KB) { + return "define.title_keyboard_p2"; + } + if (IS_P1) { + return "define.title_gamepad_p1"; + } + return "define.title_gamepad_p2"; + } + + // Scancodes que MAI capturem com a binding (reservats per a navegacio o + // global hotkeys). Tornen true → handleEvent les deixa passar al pipeline + // global perque facin la seua feina (ESC obre el prompt d'eixida, F1-F12 + // son hotkeys de sistema, RETURN/BACKSPACE/TAB son navegacio). + auto isReservedScancode(SDL_Scancode sc) -> bool { + if (sc == SDL_SCANCODE_ESCAPE) { + return true; + } + if (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12) { + return true; + } + if (sc == SDL_SCANCODE_RETURN || sc == SDL_SCANCODE_KP_ENTER) { + return true; + } + if (sc == SDL_SCANCODE_BACKSPACE || sc == SDL_SCANCODE_TAB) { + return true; + } + return false; + } + + // Conversio sense pèrdua de SDL_Scancode → int per a comparacions + // homogenies dins de sequence_ (que guarda codis de tots dos modes). + auto scancodeToInt(SDL_Scancode sc) -> int { + return static_cast(sc); + } + +} // namespace + +namespace System { + + std::unique_ptr DefineInputs::instance; + + void DefineInputs::init(Rendering::Renderer* renderer) { + if (!instance) { + instance = std::unique_ptr(new DefineInputs(renderer)); + } + } + + void DefineInputs::destroy() { instance.reset(); } + + auto DefineInputs::get() -> DefineInputs* { return instance.get(); } + + DefineInputs::DefineInputs(Rendering::Renderer* renderer) + : renderer_(renderer), + text_(renderer) {} + + auto DefineInputs::isActive() const -> bool { + return phase_ != Phase::INACTIVE; + } + + auto DefineInputs::begin(Mode mode, Player player) -> bool { + if (mode == Mode::GAMEPAD) { + // Requereix un pad assignat al jugador. + const auto* input = Input::get(); + if (input == nullptr) { + return false; + } + const int IDX = (player == Player::P1) ? 0 : 1; + if (input->getPlayerGamepad(IDX) == nullptr) { + return false; + } + } + + mode_ = mode; + player_ = player; + index_ = 0; + complete_timer_s_ = 0.0F; + l2_was_pressed_ = false; + r2_was_pressed_ = false; + + buildSequence(); + phase_ = Phase::CAPTURING; + return true; + } + + void DefineInputs::cancel() { + phase_ = Phase::INACTIVE; + sequence_.clear(); + index_ = 0; + complete_timer_s_ = 0.0F; + } + + void DefineInputs::buildSequence() { + sequence_.clear(); + if (mode_ == Mode::KEYBOARD) { + // Teclat: LEFT, RIGHT, FIRE (SHOOT), ACCELERATE (THRUST) + sequence_.push_back({.action_label_key = "define.action.left", .action = InputAction::LEFT, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.right", .action = InputAction::RIGHT, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.fire", .action = InputAction::SHOOT, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.accelerate", .action = InputAction::THRUST, .captured = -1}); + } else { + // Mando: FIRE, ACCELERATE, START, MENU + sequence_.push_back({.action_label_key = "define.action.fire", .action = InputAction::SHOOT, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.accelerate", .action = InputAction::THRUST, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.start", .action = InputAction::START, .captured = -1}); + sequence_.push_back({.action_label_key = "define.action.menu", .action = InputAction::MENU, .captured = -1}); + } + } + + auto DefineInputs::isInUse(int code) const -> bool { + return std::ranges::any_of(sequence_, [code](const Step& s) { + return s.captured == code; + }); + } + + void DefineInputs::captureAndAdvance(int code) { + if (index_ >= sequence_.size()) { + return; + } + if (isInUse(code)) { + return; // Duplicat dins de la sessio: rebutgem silenciosament + } + sequence_[index_].captured = code; + ++index_; + if (auto* audio = Audio::get(); audio != nullptr) { + audio->playSound(Defaults::ServiceMenu::ACCEPT_SOUND, Audio::Group::INTERFACE); + } + if (index_ >= sequence_.size()) { + persistAndComplete(); + } + } + + void DefineInputs::persistAndComplete() { + auto& cfg = (player_ == Player::P1) + ? ConfigYaml::engine_config.player1 + : ConfigYaml::engine_config.player2; + + if (mode_ == Mode::KEYBOARD) { + for (const Step& s : sequence_) { + switch (s.action) { + case InputAction::LEFT: + cfg.keyboard.key_left = static_cast(s.captured); + break; + case InputAction::RIGHT: + cfg.keyboard.key_right = static_cast(s.captured); + break; + case InputAction::SHOOT: + cfg.keyboard.key_shoot = static_cast(s.captured); + break; + case InputAction::THRUST: + cfg.keyboard.key_thrust = static_cast(s.captured); + break; + default: + break; // START / MENU no es redefineixen al teclat + } + } + } else { + for (const Step& s : sequence_) { + switch (s.action) { + case InputAction::SHOOT: + cfg.gamepad.button_shoot = s.captured; + break; + case InputAction::THRUST: + cfg.gamepad.button_thrust = s.captured; + break; + case InputAction::START: + cfg.gamepad.button_start = s.captured; + break; + case InputAction::MENU: + cfg.gamepad.button_menu = s.captured; + break; + default: + break; // LEFT / RIGHT no es redefineixen al mando + } + } + } + + // Aplicar canvis al runtime de l'Input i persistir a disc. + if (auto* input = Input::get(); input != nullptr) { + if (player_ == Player::P1) { + input->applyPlayer1Bindings(ConfigYaml::engine_config.player1); + } else { + input->applyPlayer2Bindings(ConfigYaml::engine_config.player2); + } + } + ConfigYaml::saveToFile(); + + phase_ = Phase::COMPLETE; + complete_timer_s_ = COMPLETE_DISPLAY_S; + } + + void DefineInputs::update(float delta_time) { + if (phase_ != Phase::COMPLETE) { + return; + } + complete_timer_s_ -= delta_time; + if (complete_timer_s_ <= 0.0F) { + cancel(); + } + } + + void DefineInputs::processTrigger(int virtual_button, bool& was_pressed, bool now) { + if (now && !was_pressed) { + captureAndAdvance(virtual_button); + } + was_pressed = now; + } + + auto DefineInputs::handleKeyboardEvent(const SDL_Event& event) -> bool { + if (event.type != SDL_EVENT_KEY_DOWN) { + return true; // Empassem la resta sense fer res + } + const SDL_Scancode SC = event.key.scancode; + if (isReservedScancode(SC)) { + // ESC, F1-F12, RETURN, BACKSPACE, TAB es deixen passar al pipeline + // global (ESC obre el prompt d'eixida; F1-F12 hotkeys, etc.). + return false; + } + captureAndAdvance(scancodeToInt(SC)); + return true; + } + + auto DefineInputs::handleGamepadEvent(const SDL_Event& event) -> bool { + // KEY_DOWN no es per al rebind de mando: deixem que el global el + // gestioni (ex. ESC → prompt d'eixida, F12 → tanca menu, etc.). + if (event.type == SDL_EVENT_KEY_DOWN) { + return false; + } + + // Filtrar events al pad del jugador actiu. + const auto* input = Input::get(); + if (input == nullptr) { + return true; + } + const int IDX = (player_ == Player::P1) ? 0 : 1; + auto pad = input->getPlayerGamepad(IDX); + if (!pad) { + return true; + } + + if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + if (event.gbutton.which != pad->instance_id) { + return true; + } + captureAndAdvance(static_cast(event.gbutton.button)); + return true; + } + + if (event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { + if (event.gaxis.which != pad->instance_id) { + return true; + } + const auto AXIS = static_cast(event.gaxis.axis); + const Sint16 VAL = event.gaxis.value; + if (AXIS == SDL_GAMEPAD_AXIS_LEFT_TRIGGER) { + processTrigger(TRIGGER_L2_VIRTUAL, l2_was_pressed_, VAL >= TRIGGER_THRESHOLD); + } else if (AXIS == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) { + processTrigger(TRIGGER_R2_VIRTUAL, r2_was_pressed_, VAL >= TRIGGER_THRESHOLD); + } + // Sticks LEFTX/LEFTY/RIGHTX/RIGHTY: ignorats (no son redefinibles). + (void)STICK_THRESHOLD; + return true; + } + + return true; + } + + auto DefineInputs::handleEvent(const SDL_Event& event) -> bool { + if (phase_ == Phase::INACTIVE) { + return false; + } + // SDL_EVENT_QUIT i WINDOW_CLOSE_REQUESTED han de poder tancar la + // finestra encara que el modal estiga obert; els passem al pipeline. + if (event.type == SDL_EVENT_QUIT || + event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { + return false; + } + if (phase_ == Phase::COMPLETE) { + // Mentre mostrem el missatge OK, empassem la resta d'events sense + // capturar perque l'usuari no puga avançar accions sense voler. + return true; + } + if (mode_ == Mode::KEYBOARD) { + return handleKeyboardEvent(event); + } + return handleGamepadEvent(event); + } + + void DefineInputs::draw() const { + if (phase_ == Phase::INACTIVE) { + return; + } + + using namespace Defaults::ServiceMenu; + + // Caixa centrada, dimensions fixes (no depen del contingut a redefinir). + constexpr float BOX_W = 560.0F; + constexpr float BOX_H = 280.0F; + const float BOX_X = (CANVAS_W - BOX_W) * 0.5F; + const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F; + + // Fons + brackets als 4 cantons (estil HUD del menu de servei). + fillRect(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H, BG_COLOR); + + const auto T = static_cast(CORNER_THICKNESS); + const auto AH = static_cast(CORNER_ARM_H); + const auto AV = static_cast(CORNER_ARM_V); + fillRect(renderer_, BOX_X, BOX_Y, AH, T, CORNER_COLOR); + fillRect(renderer_, BOX_X, BOX_Y, T, AV, CORNER_COLOR); + fillRect(renderer_, BOX_X + BOX_W - AH, BOX_Y, AH, T, CORNER_COLOR); + fillRect(renderer_, BOX_X + BOX_W - T, BOX_Y, T, AV, CORNER_COLOR); + fillRect(renderer_, BOX_X, BOX_Y + BOX_H - T, AH, T, CORNER_COLOR); + fillRect(renderer_, BOX_X, BOX_Y + BOX_H - AV, T, AV, CORNER_COLOR); + fillRect(renderer_, BOX_X + BOX_W - AH, BOX_Y + BOX_H - T, AH, T, CORNER_COLOR); + fillRect(renderer_, BOX_X + BOX_W - T, BOX_Y + BOX_H - AV, T, AV, CORNER_COLOR); + + const std::string TITLE = Locale::get().text(titleKey(mode_, player_)); + const float TITLE_W = Graphics::VectorText::getTextWidth(TITLE, TITLE_SCALE, TEXT_SPACING); + const float TITLE_X = BOX_X + ((BOX_W - TITLE_W) * 0.5F); + const float TITLE_Y = BOX_Y + 26.0F; + text_.render(TITLE, Vec2{.x = TITLE_X, .y = TITLE_Y}, TITLE_SCALE, TEXT_SPACING, 1.0F, TITLE_COLOR); + + if (phase_ == Phase::COMPLETE) { + const std::string OK = Locale::get().text("define.complete"); + constexpr float OK_SCALE = 0.7F; + const float OK_W = Graphics::VectorText::getTextWidth(OK, OK_SCALE, TEXT_SPACING); + const float OK_X = BOX_X + ((BOX_W - OK_W) * 0.5F); + const float OK_Y = BOX_Y + (BOX_H * 0.5F) - 10.0F; + constexpr SDL_Color OK_COLOR{.r = 120, .g = 255, .b = 140, .a = 255}; + text_.render(OK, Vec2{.x = OK_X, .y = OK_Y}, OK_SCALE, TEXT_SPACING, 1.0F, OK_COLOR); + return; + } + + // Instruccio (premeu tecla / boto) + accio actual + progres. + const std::string PROMPT = Locale::get().text( + mode_ == Mode::KEYBOARD ? "define.press_key" : "define.press_button"); + const float PROMPT_W = Graphics::VectorText::getTextWidth(PROMPT, ITEM_SCALE, TEXT_SPACING); + const float PROMPT_X = BOX_X + ((BOX_W - PROMPT_W) * 0.5F); + const float PROMPT_Y = BOX_Y + 86.0F; + text_.render(PROMPT, Vec2{.x = PROMPT_X, .y = PROMPT_Y}, ITEM_SCALE, TEXT_SPACING, 1.0F, SUBTITLE_COLOR); + + if (index_ < sequence_.size()) { + const std::string ACTION = Locale::get().text(sequence_[index_].action_label_key); + constexpr float ACTION_SCALE = 0.9F; + const float ACTION_W = Graphics::VectorText::getTextWidth(ACTION, ACTION_SCALE, TEXT_SPACING); + const float ACTION_X = BOX_X + ((BOX_W - ACTION_W) * 0.5F); + const float ACTION_Y = BOX_Y + 130.0F; + text_.render(ACTION, Vec2{.x = ACTION_X, .y = ACTION_Y}, ACTION_SCALE, TEXT_SPACING, 1.0F, CURSOR_COLOR); + } + + const std::string PROGRESS = std::format("{}/{}", index_ + 1, sequence_.size()); + constexpr float PROG_SCALE = 0.4F; + const float PROG_W = Graphics::VectorText::getTextWidth(PROGRESS, PROG_SCALE, TEXT_SPACING); + const float PROG_X = BOX_X + ((BOX_W - PROG_W) * 0.5F); + const float PROG_Y = BOX_Y + 200.0F; + text_.render(PROGRESS, Vec2{.x = PROG_X, .y = PROG_Y}, PROG_SCALE, TEXT_SPACING, 1.0F, LABEL_COLOR); + } + +} // namespace System diff --git a/source/core/input/define_inputs.hpp b/source/core/input/define_inputs.hpp new file mode 100644 index 0000000..e117316 --- /dev/null +++ b/source/core/input/define_inputs.hpp @@ -0,0 +1,107 @@ +// define_inputs.hpp - Overlay modal de redefinici de controls (singleton) +// © 2026 JailDesigner +// +// Sub-mòdul inspirat en aee_arcade/source/core/input/define_buttons. Quan el +// menú de servei dispara una acció "Redefinir tecles/botons P1/P2", aquest +// singleton pren el control: pinta una caixa central, captura events SDL i +// avança per una seqüència fixa d'accions, persistint les noves assignacions +// a config.yaml en acabar. +// +// Cicle de vida: +// 1. begin(mode, player) → construeix la seqüència (4 passos) i activa +// l'overlay. Per a GAMEPAD, retorna false si el jugador no té pad. +// 2. handleEvent() captura el següent event vàlid; ESC cancel·la sense +// desar; duplicats dins de la sessió es rebutgen silenciosament. +// 3. Quan la seqüència es completa, persistim a engine_config + saveToFile, +// reapliquem els bindings i mostrem un missatge "OK" durant 1.5 s +// abans d'auto-tancar-se. +// +// El routing d'events es fa des de GlobalEvents::handle: mentre isActive() +// retorna true, tots els events SDL es desvien aquí i no arriben al joc ni +// al menú de servei. + +#pragma once + +#include + +#include +#include +#include +#include + +#include "core/graphics/vector_text.hpp" +#include "core/input/input_types.hpp" +#include "core/rendering/render_context.hpp" + +namespace System { + + class DefineInputs { + public: + enum class Mode : std::uint8_t { KEYBOARD, + GAMEPAD }; + enum class Player : std::uint8_t { P1, + P2 }; + + static void init(Rendering::Renderer* renderer); + static void destroy(); + [[nodiscard]] static auto get() -> DefineInputs*; + + // Comença la sessió. Retorna false per a GAMEPAD si el jugador no té + // cap pad assignat (el caller hauria de notificar a l'usuari abans). + auto begin(Mode mode, Player player) -> bool; + void cancel(); + + [[nodiscard]] auto isActive() const -> bool; + + void update(float delta_time); + void draw() const; + + // Retorna true si l'event s'ha consumit (és a dir, mentre l'overlay + // és actiu sempre consumeix tot per evitar passages al joc o menú). + auto handleEvent(const SDL_Event& event) -> bool; + + private: + explicit DefineInputs(Rendering::Renderer* renderer); + + enum class Phase : std::uint8_t { + INACTIVE, + CAPTURING, + COMPLETE, // mostra missatge OK breu abans d'auto-cancel + }; + + struct Step { + std::string action_label_key; // p.ex. "define.action.left" + InputAction action; // mapeig a la struct PlayerBindings + int captured{-1}; // scancode o button code; -1 = sense capturar + }; + + void buildSequence(); + [[nodiscard]] auto isInUse(int code) const -> bool; + void captureAndAdvance(int code); + void persistAndComplete(); + + // Handlers especialitzats segons mode_. + auto handleKeyboardEvent(const SDL_Event& event) -> bool; + auto handleGamepadEvent(const SDL_Event& event) -> bool; + + // Edge-detect per als triggers L2/R2 com a botons virtuals. + void processTrigger(int virtual_button, bool& was_pressed, bool now); + + Rendering::Renderer* renderer_; + Graphics::VectorText text_; + + Phase phase_{Phase::INACTIVE}; + Mode mode_{Mode::KEYBOARD}; + Player player_{Player::P1}; + std::vector sequence_; + std::size_t index_{0}; + float complete_timer_s_{0.0F}; + + // Estat d'edge-detect dels triggers durant la sessió GAMEPAD. + bool l2_was_pressed_{false}; + bool r2_was_pressed_{false}; + + static std::unique_ptr instance; + }; + +} // namespace System diff --git a/source/core/input/input.cpp b/source/core/input/input.cpp index a668690..80ff7e7 100644 --- a/source/core/input/input.cpp +++ b/source/core/input/input.cpp @@ -8,6 +8,10 @@ #include // Para unordered_map, _Node_iterator, operator==, _Node_iterator_base, _Node_const_iterator #include // Para move +#include "core/locale/locale.hpp" +#include "core/system/notifier.hpp" +#include "core/utils/string_utils.hpp" + // Singleton Input* Input::instance = nullptr; @@ -373,9 +377,25 @@ void Input::update() { // --- MANDOS --- for (const auto& gamepad : gamepads_) { + // LEFT i RIGHT NO son redefinibles al mando (assumits dpad o stick). + // Llegim el left stick X i el fusionem amb l'estat del dpad: qualsevol + // de les dos fonts activa l'accio. Llindar AXIS_THRESHOLD (30000). + const Sint16 STICK_X = SDL_GetGamepadAxis(gamepad->pad, SDL_GAMEPAD_AXIS_LEFTX); + const bool STICK_LEFT = STICK_X < -AXIS_THRESHOLD; + const bool STICK_RIGHT = STICK_X > AXIS_THRESHOLD; + for (auto& binding : gamepad->bindings) { bool button_is_down_now = static_cast(SDL_GetGamepadButton(gamepad->pad, static_cast(binding.second.button))) != 0; + // Per a LEFT/RIGHT, fer un OR amb el stick X. La resta d'accions + // (THRUST/SHOOT/START/MENU) ignoren el stick aqui — si es vol + // dispar amb trigger L2/R2 cal binding amb codi 100/101. + if (binding.first == Action::LEFT) { + button_is_down_now = button_is_down_now || STICK_LEFT; + } else if (binding.first == Action::RIGHT) { + button_is_down_now = button_is_down_now || STICK_RIGHT; + } + // El estado .is_held del fotograma anterior nos sirve para saber si es un pulso nuevo binding.second.just_pressed = button_is_down_now && !binding.second.is_held; binding.second.is_held = button_is_down_now; @@ -407,6 +427,16 @@ auto Input::addGamepad(int device_index) -> std::string { auto name = gamepad->name; std::cout << "Gamepad connected (" << name << ")" << '\n'; gamepads_.push_back(std::move(gamepad)); + + // Toast a pantalla. Pot ser nullptr durant discoverGamepads() inicial + // (l'Input::init() es crida abans que el Director instanciï el Notifier). + if (auto* notifier = System::Notifier::get(); notifier != nullptr) { + notifier->notifyInfo(localeSubstitute( + Locale::get().text("notification.gamepad_connected"), + "{name}", + Utils::toUpperAscii(name))); + } + return name + " CONNECTED"; } @@ -419,6 +449,14 @@ auto Input::removeGamepad(SDL_JoystickID id) -> std::string { std::string name = (*it)->name; std::cout << "Gamepad disconnected (" << name << ")" << '\n'; gamepads_.erase(it); + + if (auto* notifier = System::Notifier::get(); notifier != nullptr) { + notifier->notifyInfo(localeSubstitute( + Locale::get().text("notification.gamepad_disconnected"), + "{name}", + Utils::toUpperAscii(name))); + } + return name + " DISCONNECTED"; } std::cerr << "No se encontró el gamepad con ID " << id << '\n'; @@ -465,6 +503,33 @@ auto Input::findAvailableGamepadByName(const std::string& gamepad_name) -> std:: // ========== MÉTODOS ESPECÍFICOS POR JUGADOR (ORNI) ========== +// Cerca el gamepad assignat a un jugador. Prioritat path > name. Si els +// dos camps venen buits o no n'hi ha cap match retornem nullptr (sense +// mando explicit). L'autoassignacio inicial es resol al boot. +auto Input::resolvePlayerGamepad(const Config::PlayerBindings& bindings) -> std::shared_ptr { + if (gamepads_.empty()) { + return nullptr; + } + + if (!bindings.gamepad_path.empty()) { + for (const auto& pad : gamepads_) { + if (pad && pad->path == bindings.gamepad_path) { + return pad; + } + } + } + + if (!bindings.gamepad_name.empty()) { + for (const auto& pad : gamepads_) { + if (pad && pad->name == bindings.gamepad_name) { + return pad; + } + } + } + + return nullptr; +} + // Aplica configuración de controles del player 1 void Input::applyPlayer1Bindings(const Config::PlayerBindings& bindings) { // 1. Aplicar bindings de teclado (NO usar bindKey, llenar mapa específico) @@ -474,15 +539,8 @@ void Input::applyPlayer1Bindings(const Config::PlayerBindings& bindings) { player1_keyboard_bindings_[Action::SHOOT].scancode = bindings.keyboard.key_shoot; player1_keyboard_bindings_[Action::START].scancode = bindings.keyboard.key_start; - // 2. Encontrar gamepad por nombre (o usar primer gamepad como fallback) - std::shared_ptr gamepad = nullptr; - if (bindings.gamepad_name.empty()) { - // Fallback: usar primer gamepad disponible - gamepad = (!gamepads_.empty()) ? gamepads_[0] : nullptr; - } else { - // Buscar por nombre - gamepad = findAvailableGamepadByName(bindings.gamepad_name); - } + // 2. Resoldre gamepad per path/name + std::shared_ptr gamepad = resolvePlayerGamepad(bindings); if (!gamepad) { player1_gamepad_ = nullptr; @@ -494,6 +552,8 @@ void Input::applyPlayer1Bindings(const Config::PlayerBindings& bindings) { gamepad->bindings[Action::RIGHT].button = bindings.gamepad.button_right; gamepad->bindings[Action::THRUST].button = bindings.gamepad.button_thrust; gamepad->bindings[Action::SHOOT].button = bindings.gamepad.button_shoot; + gamepad->bindings[Action::START].button = bindings.gamepad.button_start; + gamepad->bindings[Action::MENU].button = bindings.gamepad.button_menu; // 4. Cachear referencia player1_gamepad_ = gamepad; @@ -508,15 +568,8 @@ void Input::applyPlayer2Bindings(const Config::PlayerBindings& bindings) { player2_keyboard_bindings_[Action::SHOOT].scancode = bindings.keyboard.key_shoot; player2_keyboard_bindings_[Action::START].scancode = bindings.keyboard.key_start; - // 2. Encontrar gamepad por nombre (o usar segundo gamepad como fallback) - std::shared_ptr gamepad = nullptr; - if (bindings.gamepad_name.empty()) { - // Fallback: usar segundo gamepad disponible - gamepad = (gamepads_.size() > 1) ? gamepads_[1] : nullptr; - } else { - // Buscar por nombre - gamepad = findAvailableGamepadByName(bindings.gamepad_name); - } + // 2. Resoldre gamepad per path/name + std::shared_ptr gamepad = resolvePlayerGamepad(bindings); if (!gamepad) { player2_gamepad_ = nullptr; @@ -528,6 +581,8 @@ void Input::applyPlayer2Bindings(const Config::PlayerBindings& bindings) { gamepad->bindings[Action::RIGHT].button = bindings.gamepad.button_right; gamepad->bindings[Action::THRUST].button = bindings.gamepad.button_thrust; gamepad->bindings[Action::SHOOT].button = bindings.gamepad.button_shoot; + gamepad->bindings[Action::START].button = bindings.gamepad.button_start; + gamepad->bindings[Action::MENU].button = bindings.gamepad.button_menu; // 4. Cachear referencia player2_gamepad_ = gamepad; @@ -555,6 +610,17 @@ auto Input::checkActionPlayer1(Action action, bool repeat) -> bool { return keyboard_active || gamepad_active; } +// Retorna el pad assignat (0=P1, 1=P2). Pot ser nullptr. +auto Input::getPlayerGamepad(int player_index) const -> std::shared_ptr { + if (player_index == 0) { + return player1_gamepad_; + } + if (player_index == 1) { + return player2_gamepad_; + } + return nullptr; +} + // Consulta de input para player 2 auto Input::checkActionPlayer2(Action action, bool repeat) -> bool { // Comprobar teclado con el mapa específico de P2 diff --git a/source/core/input/input.hpp b/source/core/input/input.hpp index 541c3b4..6fdc628 100644 --- a/source/core/input/input.hpp +++ b/source/core/input/input.hpp @@ -62,7 +62,9 @@ class Input { {Action::LEFT, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_DPAD_LEFT)}}, {Action::RIGHT, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_DPAD_RIGHT)}}, {Action::THRUST, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_WEST)}}, - {Action::SHOOT, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_SOUTH)}}} {} + {Action::SHOOT, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_SOUTH)}}, + {Action::START, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_START)}}, + {Action::MENU, ButtonState{.button = static_cast(SDL_GAMEPAD_BUTTON_BACK)}}} {} ~Gamepad() { if (pad != nullptr) { @@ -107,6 +109,10 @@ class Input { auto checkActionPlayer1(Action action, bool repeat = true) -> bool; auto checkActionPlayer2(Action action, bool repeat = true) -> bool; + // Accés al gamepad assignat per jugador (0=P1, 1=P2). nullptr si no n'hi + // ha cap d'assignat o connectat. Usat per la UI de redefinició de botons. + [[nodiscard]] auto getPlayerGamepad(int player_index) const -> std::shared_ptr; + // Check if any player pressed any action from a list auto checkAnyPlayerAction(const std::span& actions, bool repeat = DO_NOT_ALLOW_REPEAT) -> bool; @@ -142,6 +148,7 @@ class Input { auto removeGamepad(SDL_JoystickID id) -> std::string; void addGamepadMappingsFromFile(); void discoverGamepads(); + auto resolvePlayerGamepad(const Config::PlayerBindings& bindings) -> std::shared_ptr; // --- Variables miembro --- static Input* instance; // Instancia única del singleton diff --git a/source/core/input/input_types.cpp b/source/core/input/input_types.cpp index d4d4008..a425b59 100644 --- a/source/core/input/input_types.cpp +++ b/source/core/input/input_types.cpp @@ -6,6 +6,8 @@ const std::unordered_map ACTION_TO_STRING = { {InputAction::RIGHT, "RIGHT"}, {InputAction::THRUST, "THRUST"}, {InputAction::SHOOT, "SHOOT"}, + {InputAction::START, "START"}, + {InputAction::MENU, "MENU"}, {InputAction::WINDOW_INC_ZOOM, "WINDOW_INC_ZOOM"}, {InputAction::WINDOW_DEC_ZOOM, "WINDOW_DEC_ZOOM"}, {InputAction::TOGGLE_FULLSCREEN, "TOGGLE_FULLSCREEN"}, @@ -18,6 +20,8 @@ const std::unordered_map STRING_TO_ACTION = { {"RIGHT", InputAction::RIGHT}, {"THRUST", InputAction::THRUST}, {"SHOOT", InputAction::SHOOT}, + {"START", InputAction::START}, + {"MENU", InputAction::MENU}, {"WINDOW_INC_ZOOM", InputAction::WINDOW_INC_ZOOM}, {"WINDOW_DEC_ZOOM", InputAction::WINDOW_DEC_ZOOM}, {"TOGGLE_FULLSCREEN", InputAction::TOGGLE_FULLSCREEN}, diff --git a/source/core/input/input_types.hpp b/source/core/input/input_types.hpp index 40a7997..0f5d0db 100644 --- a/source/core/input/input_types.hpp +++ b/source/core/input/input_types.hpp @@ -15,6 +15,7 @@ enum class InputAction : std::uint8_t { // Acciones de entrada posibles en el j THRUST, // Acelerar SHOOT, // Disparar START, // Empezar match + MENU, // Abrir/cerrar menu de servicio (equivalent a F12) // Inputs de sistema (globales) WINDOW_INC_ZOOM, // F2 diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 36f8fd9..4999fcd 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -13,6 +13,7 @@ #include "core/audio/audio_adapter.hpp" #include "core/defaults/window.hpp" #include "core/graphics/shape_loader.hpp" +#include "core/input/define_inputs.hpp" #include "core/input/input.hpp" #include "core/input/mouse.hpp" #include "core/locale/locale.hpp" @@ -108,6 +109,34 @@ Director::Director(int argc, char* argv[]) // Inicialitzar sistema de input Input::init("data/gamecontrollerdb.txt"); + // Autoassignacio de primer arranque: si cap dels dos jugadors te mando + // assignat al config, repartim els que hi haja detectats (P1 = pad 0, + // P2 = pad 1 si existeix) i ho persistim. Aixo nomes dispara amb tots + // dos buits perque un "SENSE MANDO" explicit ha de sobreviure entre + // arrancades. + { + auto& p1 = cfg_->player1; + auto& p2 = cfg_->player2; + const bool BOTH_EMPTY = p1.gamepad_name.empty() && p1.gamepad_path.empty() && p2.gamepad_name.empty() && p2.gamepad_path.empty(); + if (BOTH_EMPTY) { + const auto& pads = Input::get()->getGamepads(); + bool changed = false; + if (!pads.empty() && pads[0]) { + p1.gamepad_name = pads[0]->name; + p1.gamepad_path = pads[0]->path; + changed = true; + } + if (pads.size() > 1 && pads[1]) { + p2.gamepad_name = pads[1]->name; + p2.gamepad_path = pads[1]->path; + changed = true; + } + if (changed) { + ConfigYaml::saveToFile(); + } + } + } + // Aplicar configuración de controls dels jugadors Input::get()->applyPlayer1Bindings(cfg_->player1); Input::get()->applyPlayer2Bindings(cfg_->player2); @@ -178,6 +207,7 @@ Director::Director(int argc, char* argv[]) System::Notifier::init(sdl_->getRenderer()); System::ServiceMenu::init(sdl_->getRenderer(), sdl_.get(), debug_overlay_.get()); + System::DefineInputs::init(sdl_->getRenderer()); last_ticks_ms_ = SDL_GetTicks(); } @@ -192,6 +222,7 @@ Director::~Director() { // l'hem de cridar nosaltres. current_scene_.reset(); debug_overlay_.reset(); + System::DefineInputs::destroy(); System::ServiceMenu::destroy(); System::Notifier::destroy(); context_.reset(); @@ -376,6 +407,9 @@ auto Director::iterate() -> SDL_AppResult { if (auto* menu = System::ServiceMenu::get(); menu != nullptr) { menu->update(delta_time); } + if (auto* di = System::DefineInputs::get(); di != nullptr) { + di->update(delta_time); + } Audio::update(); // Si la swapchain no està disponible (finestra minimitzada, etc.), @@ -389,9 +423,17 @@ auto Director::iterate() -> SDL_AppResult { if (const auto* notifier = System::Notifier::get(); notifier != nullptr) { notifier->draw(); // toast: per damunt de tot } - if (const auto* menu = System::ServiceMenu::get(); menu != nullptr) { + // Mentre l'overlay de redefinicio esta actiu, amaguem el menu de servei + // (encara queda "open" per a absorbir events un cop el modal s'auto-tanqui, + // pero no es pinta per no confondre's visualment amb el modal). + const auto* di = System::DefineInputs::get(); + const bool DEFINE_ACTIVE = (di != nullptr) && di->isActive(); + if (const auto* menu = System::ServiceMenu::get(); menu != nullptr && !DEFINE_ACTIVE) { menu->draw(); // service menu: per damunt fins i tot dels toasts } + if (di != nullptr) { + di->draw(); // overlay de rebind: per damunt de tot + } sdl_->present(); return SDL_APP_CONTINUE; } diff --git a/source/core/system/global_events.cpp b/source/core/system/global_events.cpp index 6b06f25..9fa22bf 100644 --- a/source/core/system/global_events.cpp +++ b/source/core/system/global_events.cpp @@ -5,6 +5,7 @@ #include +#include "core/input/define_inputs.hpp" #include "core/input/input.hpp" #include "core/input/mouse.hpp" #include "core/locale/locale.hpp" @@ -22,24 +23,74 @@ namespace GlobalEvents { namespace { - // Reenvia el KEY_DOWN al menu de servei si esta obert i la tecla no - // es F1-F12 ni ESC (que sempre passen com a globals). Retorna true si - // el menu l'ha consumit. + // Reenvia events al menu de servei si esta obert. Accepta: + // - KEY_DOWN (excepte F1-F12 i ESC, que sempre passen com a globals) + // - GAMEPAD_BUTTON_DOWN (per navegacio amb dpad + FIRE/ACCELERATE) + // - GAMEPAD_AXIS_MOTION (per navegacio amb stick) + // Retorna true si l'event s'ha entregat al menu. auto forwardToServiceMenu(const SDL_Event& event) -> bool { - if (event.type != SDL_EVENT_KEY_DOWN) { - return false; - } auto* menu = System::ServiceMenu::get(); if (menu == nullptr || !menu->isOpen()) { return false; } - const SDL_Scancode SC = event.key.scancode; - const bool PASSTHROUGH = (SC == SDL_SCANCODE_ESCAPE) || - (SC >= SDL_SCANCODE_F1 && SC <= SDL_SCANCODE_F12); - if (PASSTHROUGH) { + if (event.type == SDL_EVENT_KEY_DOWN) { + const SDL_Scancode SC = event.key.scancode; + const bool PASSTHROUGH = (SC == SDL_SCANCODE_ESCAPE) || + (SC >= SDL_SCANCODE_F1 && SC <= SDL_SCANCODE_F12); + if (PASSTHROUGH) { + return false; + } + menu->handleEvent(event); + return true; + } + if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { + menu->handleEvent(event); + return true; + } + return false; + } + + // Engoleix els events que DefineInputs vol consumir mentre l'overlay + // es actiu. Els events que el modul torna a passar (QUIT, ESC) cauen + // cap al pipeline normal i poden tancar la finestra o obrir el prompt + // d'eixida sense haver de completar la sequencia. + auto consumeIfDefineActive(const SDL_Event& event) -> bool { + auto* di = System::DefineInputs::get(); + if (di == nullptr || !di->isActive()) { return false; } - menu->handleEvent(event); + return di->handleEvent(event); + } + + // Botó MENU al mando d'algun jugador → alterna el menú de servei + // (mateix comportament que F12 al teclat). Retorna true si l'event és + // un GAMEPAD_BUTTON_DOWN consumit. + auto handleGamepadMenuButton(const SDL_Event& event) -> bool { + if (event.type != SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + return false; + } + auto* input = Input::get(); + if (input == nullptr) { + return false; + } + auto match_player = [&](int player_index) { + auto pad = input->getPlayerGamepad(player_index); + if (!pad || pad->instance_id != event.gbutton.which) { + return false; + } + auto it = pad->bindings.find(InputAction::MENU); + if (it == pad->bindings.end()) { + return false; + } + return it->second.button == static_cast(event.gbutton.button); + }; + if (!match_player(0) && !match_player(1)) { + return false; + } + if (auto* menu = System::ServiceMenu::get(); menu != nullptr) { + menu->toggle(); + } return true; } @@ -52,6 +103,12 @@ namespace GlobalEvents { std::cout << "[Input] " << event_msg << '\n'; } + // 1b. Si l'overlay de redefinicio esta actiu, engoleix tots els events + // (cap arriba al joc, al menu de servei ni als hotkeys F1-F12). + if (consumeIfDefineActive(event)) { + return true; + } + // 2. Procesar SDL_EVENT_QUIT directamente (no es input de juego) if (event.type == SDL_EVENT_QUIT) { context.setNextScene(SceneType::EXIT); @@ -62,6 +119,11 @@ namespace GlobalEvents { // 3. Gestió del ratolí (auto-ocultar) Mouse::handleEvent(event); + // 3b. Botó MENU al mando (equivalent a F12) + if (handleGamepadMenuButton(event)) { + return true; + } + // 4. Service Menu (F12): consumeix tot KEY_DOWN excepte tecles de // funció (F1-F12) i ESC, que continuen sent globals (zoom, fullscreen, // vsync, AA, postfx, locale, exit prompt). Aixi el menu captura diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index 6960e29..d5432c3 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -15,11 +15,15 @@ #include "core/defaults/audio.hpp" #include "core/defaults/rendering.hpp" #include "core/defaults/service_menu.hpp" +#include "core/input/define_inputs.hpp" +#include "core/input/input.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" #include "core/system/debug_overlay.hpp" +#include "core/system/notifier.hpp" #include "core/system/relaunch.hpp" #include "core/types.hpp" +#include "core/utils/string_utils.hpp" #include "game/config_yaml.hpp" #include "project.h" @@ -54,17 +58,6 @@ namespace { } } - // VectorText nomes admet ASCII en majuscules. El git hash sortit de git - // rev-parse es lowercase (a-f), aixi que el passem a uppercase per al - // display sense modificar Project::GIT_HASH. - auto toUpperAscii(const std::string& s) -> std::string { - std::string result = s; - for (char& c : result) { - c = static_cast(std::toupper(static_cast(c))); - } - return result; - } - // Resol el text del label d'un item: prioritza label_text (literal) sobre // label_key (locale). Retorna cadena buida si tots dos son buits. auto resolveLabel(const System::ServiceMenu::Item& item) -> std::string { @@ -77,6 +70,107 @@ namespace { return Locale::get().text(item.label_key); } + // ---- Helpers de la pagina CONTROLS ---- + + auto padDisplayName(int player_index) -> std::string { + const auto* input = Input::get(); + if (input == nullptr) { + return Locale::get().text("service_menu.controls_no_pad"); + } + auto pad = input->getPlayerGamepad(player_index); + if (!pad) { + return Locale::get().text("service_menu.controls_no_pad"); + } + return Utils::toUpperAscii(pad->name); + } + + // Index del pad assignat dins de la llista. Retorna pads.size() per + // representar el slot virtual SENSE MANDO (al final del cycle). + auto findAssignedIndex(const std::vector>& pads, + const Config::PlayerBindings& pcfg) -> std::size_t { + for (std::size_t i = 0; i < pads.size(); ++i) { + if (pads[i] && !pcfg.gamepad_path.empty() && pads[i]->path == pcfg.gamepad_path) { + return i; + } + } + for (std::size_t i = 0; i < pads.size(); ++i) { + if (pads[i] && !pcfg.gamepad_name.empty() && pads[i]->name == pcfg.gamepad_name) { + return i; + } + } + return pads.size(); // Slot virtual "sense mando" + } + + // Aplica les noves assignacions a Input. Si ha hagut swap, refresca els + // dos jugadors; en cas contrari nomes el que ha canviat. + void reapplyBindings(int player_index, bool swap_other) { + auto* input = Input::get(); + if (input == nullptr) { + return; + } + if (player_index == 0) { + input->applyPlayer1Bindings(ConfigYaml::engine_config.player1); + if (swap_other) { + input->applyPlayer2Bindings(ConfigYaml::engine_config.player2); + } + } else { + input->applyPlayer2Bindings(ConfigYaml::engine_config.player2); + if (swap_other) { + input->applyPlayer1Bindings(ConfigYaml::engine_config.player1); + } + } + } + + // Assigna un pad concret (per nom+path) a un jugador i ho persisteix. + // Si l'altre jugador ja tenia eixe pad, fa swap: l'altre rep + // l'assignacio prèvia d'aquest. Per desasignar, passar new_name i + // new_path buits. + void assignPadToPlayer(int player_index, const std::string& new_name, const std::string& new_path) { + auto& pcfg = (player_index == 0) + ? ConfigYaml::engine_config.player1 + : ConfigYaml::engine_config.player2; + auto& other = (player_index == 0) + ? ConfigYaml::engine_config.player2 + : ConfigYaml::engine_config.player1; + + // Detecta conflicte amb l'altre jugador per fer swap. Prioritzem + // path (mateix criteri que resolvePlayerGamepad); si nomes tenim + // nom (mando reconegut sense path), comparem per nom. + const bool CONFLICT = !new_path.empty() && other.gamepad_path == new_path; + const bool CONFLICT_BY_NAME = !new_name.empty() && new_path.empty() && + other.gamepad_name == new_name; + const bool DO_SWAP = CONFLICT || CONFLICT_BY_NAME; + + const std::string PREV_NAME = pcfg.gamepad_name; + const std::string PREV_PATH = pcfg.gamepad_path; + + pcfg.gamepad_name = new_name; + pcfg.gamepad_path = new_path; + if (DO_SWAP) { + other.gamepad_name = PREV_NAME; + other.gamepad_path = PREV_PATH; + } + + reapplyBindings(player_index, DO_SWAP); + ConfigYaml::saveToFile(); + } + + // Arranca un rebind o avisa si el jugador no te pad. Retorna true si el + // rebind ha començat (el caller ha de tancar el menu). + auto startDefine(System::DefineInputs::Mode mode, System::DefineInputs::Player pl) -> bool { + auto* di = System::DefineInputs::get(); + if (di == nullptr) { + return false; + } + if (!di->begin(mode, pl)) { + if (auto* n = System::Notifier::get(); n != nullptr) { + n->notifyWarn(Locale::get().text("define.no_gamepad")); + } + return false; + } + return true; + } + } // namespace namespace System { @@ -153,6 +247,7 @@ namespace System { makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }), makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }), + makeSubmenu("service_menu.controls", [this] { pushPage(buildControlsPage()); }), makeSubmenu("service_menu.system", [this] { pushPage(buildSystemPage()); }), }; stack_.clear(); @@ -437,13 +532,138 @@ namespace System { return page; } + namespace { + + auto makeDefineItem(const char* label_key, DefineInputs::Mode mode, DefineInputs::Player pl) -> ServiceMenu::Item { + return ServiceMenu::Item{ + .kind = ServiceMenu::Kind::ACTION, + .label_key = label_key, + .label_text = {}, + .selectable = true, + // El menu de servei NO es tanca: queda obert per sota de + // l'overlay i absorbira qualsevol event que arribi un cop + // l'overlay s'haja auto-cancel·lat. + .on_activate = [mode, pl] { startDefine(mode, pl); }, + .get_value_text = {}, + .on_change = {}, + }; + } + + } // namespace + + auto ServiceMenu::buildControlsPage() -> Page { + Page page; + page.title_key = "service_menu.controls"; + page.items = { + Item{ + .kind = Kind::SUBMENU, + .label_key = "service_menu.controls_pad_p1", + .label_text = {}, + .selectable = true, + .on_activate = [this] { pushPage(buildPadPickerPage(0)); }, + .get_value_text = [] { return padDisplayName(0); }, + .on_change = {}, + }, + Item{ + .kind = Kind::SUBMENU, + .label_key = "service_menu.controls_pad_p2", + .label_text = {}, + .selectable = true, + .on_activate = [this] { pushPage(buildPadPickerPage(1)); }, + .get_value_text = [] { return padDisplayName(1); }, + .on_change = {}, + }, + makeDefineItem("service_menu.controls_define_keyboard_p1", + DefineInputs::Mode::KEYBOARD, + DefineInputs::Player::P1), + makeDefineItem("service_menu.controls_define_keyboard_p2", + DefineInputs::Mode::KEYBOARD, + DefineInputs::Player::P2), + makeDefineItem("service_menu.controls_define_gamepad_p1", + DefineInputs::Mode::GAMEPAD, + DefineInputs::Player::P1), + makeDefineItem("service_menu.controls_define_gamepad_p2", + DefineInputs::Mode::GAMEPAD, + DefineInputs::Player::P2), + }; + return page; + } + + auto ServiceMenu::buildPadPickerPage(int player_index) -> Page { + Page page; + page.title_key = (player_index == 0) + ? "service_menu.controls_pad_p1" + : "service_menu.controls_pad_p2"; + + const auto* input = Input::get(); + if (input == nullptr) { + return page; + } + const auto& pads = input->getGamepads(); + const auto& pcfg = (player_index == 0) + ? ConfigYaml::engine_config.player1 + : ConfigYaml::engine_config.player2; + const auto& other = (player_index == 0) + ? ConfigYaml::engine_config.player2 + : ConfigYaml::engine_config.player1; + + // Cursor inicial sobre el pad assignat, o sobre "SENSE MANDO" + // (ultim item) si el jugador no en te cap. + page.cursor = findAssignedIndex(pads, pcfg); + + for (const auto& pad : pads) { + if (!pad) { + continue; + } + std::string label = Utils::toUpperAscii(pad->name); + // Sufix (PX) nomes si el mando el te l'altre jugador, perque + // l'usuari sapiga que assignar-lo li'l "robarà". + const bool OTHER_HAS_BY_PATH = !other.gamepad_path.empty() && other.gamepad_path == pad->path; + const bool OTHER_HAS_BY_NAME = other.gamepad_path.empty() && !other.gamepad_name.empty() && other.gamepad_name == pad->name; + if (OTHER_HAS_BY_PATH || OTHER_HAS_BY_NAME) { + label += (player_index == 0) ? " (P2)" : " (P1)"; + } + const std::string PAD_NAME = pad->name; + const std::string PAD_PATH = pad->path; + const int PI = player_index; + page.items.push_back(Item{ + .kind = Kind::ACTION, + .label_key = {}, + .label_text = std::move(label), + .selectable = true, + .on_activate = [this, PI, PAD_NAME, PAD_PATH] { + assignPadToPlayer(PI, PAD_NAME, PAD_PATH); + popPage(); + }, + .get_value_text = {}, + .on_change = {}, + }); + } + + // Item final: desasignar. + const int PI = player_index; + page.items.push_back(Item{ + .kind = Kind::ACTION, + .label_key = "service_menu.controls_no_pad", + .label_text = {}, + .selectable = true, + .on_activate = [this, PI] { + assignPadToPlayer(PI, {}, {}); + popPage(); + }, + .get_value_text = {}, + .on_change = {}, + }); + return page; + } + auto ServiceMenu::buildSystemPage() -> Page { Page page; page.title_key = "service_menu.system"; // Versio + hash com a subtitol sota el titol (apagat, mes petit). // Uppercase del hash perque VectorText nomes admet majuscules. page.subtitle_provider = [] { - return std::format("V{} - {}", Project::VERSION, toUpperAscii(Project::GIT_HASH)); + return std::format("V{} - {}", Project::VERSION, Utils::toUpperAscii(Project::GIT_HASH)); }; page.items = { // REINICIAR (amb confirmacio). @@ -593,10 +813,55 @@ namespace System { } } - auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool { - if (!open_ || stack_.empty() || event.type != SDL_EVENT_KEY_DOWN) { + namespace { + + // Llindar de stick per a navegacio de menu (mig camp del rang ±32767). + // Mes baix que el del joc (30000) per a una resposta mes agil al menu. + constexpr Sint16 MENU_STICK_THRESHOLD = 16384; + // Llindar de trigger (mateix valor que MENU_TRIGGER_THRESHOLD, que + // és private). Edge a partir del 50% del rang. + constexpr Sint16 MENU_TRIGGER_THRESHOLD = 16384; + + // Retorna true si el codi de boto SDL coincideix amb l'accio + // configurada per algun dels dos jugadors (es a dir, el boto te el + // mateix codi al binding de FIRE o ACCELERATE del pad emissor). + auto buttonMatchesAction(SDL_JoystickID which, int button, InputAction action) -> bool { + const auto* input = Input::get(); + if (input == nullptr) { + return false; + } + for (int i = 0; i < 2; ++i) { + auto pad = input->getPlayerGamepad(i); + if (!pad || pad->instance_id != which) { + continue; + } + auto it = pad->bindings.find(action); + if (it != pad->bindings.end() && it->second.button == button) { + return true; + } + } return false; } + + } // namespace + + auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool { + if (!open_ || stack_.empty()) { + return false; + } + if (event.type == SDL_EVENT_KEY_DOWN) { + return handleKeyDown(event); + } + if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + return handleGamepadButton(event); + } + if (event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { + return handleGamepadAxis(event); + } + return false; + } + + auto ServiceMenu::handleKeyDown(const SDL_Event& event) -> bool { switch (event.key.scancode) { case SDL_SCANCODE_UP: moveCursor(-1); @@ -622,6 +887,101 @@ namespace System { } } + auto ServiceMenu::handleGamepadButton(const SDL_Event& event) -> bool { + const int BTN = static_cast(event.gbutton.button); + if (BTN == SDL_GAMEPAD_BUTTON_DPAD_UP) { + moveCursor(-1); + return true; + } + if (BTN == SDL_GAMEPAD_BUTTON_DPAD_DOWN) { + moveCursor(+1); + return true; + } + if (BTN == SDL_GAMEPAD_BUTTON_DPAD_LEFT) { + changeValue(-1); + return true; + } + if (BTN == SDL_GAMEPAD_BUTTON_DPAD_RIGHT) { + changeValue(+1); + return true; + } + // Botons d'accio per al pad emissor: FIRE = ENTER, ACCELERATE = BACK. + if (buttonMatchesAction(event.gbutton.which, BTN, InputAction::SHOOT)) { + activateCurrent(); + return true; + } + if (buttonMatchesAction(event.gbutton.which, BTN, InputAction::THRUST)) { + popPage(); + return true; + } + return false; + } + + void ServiceMenu::processStickX(Sint16 val) { + const bool LEFT_NOW = val < -MENU_STICK_THRESHOLD; + const bool RIGHT_NOW = val > MENU_STICK_THRESHOLD; + if (LEFT_NOW && !stick_left_held_) { + changeValue(-1); + } + if (RIGHT_NOW && !stick_right_held_) { + changeValue(+1); + } + stick_left_held_ = LEFT_NOW; + stick_right_held_ = RIGHT_NOW; + } + + void ServiceMenu::processStickY(Sint16 val) { + const bool UP_NOW = val < -MENU_STICK_THRESHOLD; + const bool DOWN_NOW = val > MENU_STICK_THRESHOLD; + if (UP_NOW && !stick_up_held_) { + moveCursor(-1); + } + if (DOWN_NOW && !stick_down_held_) { + moveCursor(+1); + } + stick_up_held_ = UP_NOW; + stick_down_held_ = DOWN_NOW; + } + + // Edge-detect d'un trigger: si creua el llindar amunt, despatxa + // ENTER/BACK segons el binding (FIRE/ACCEL) que apunta al codi + // virtual del trigger (100 = L2, 101 = R2). + void ServiceMenu::processTriggerEdge(SDL_JoystickID which, Sint16 val, int virtual_button, bool& held) { + const bool NOW = val > MENU_TRIGGER_THRESHOLD; + if (NOW && !held) { + if (buttonMatchesAction(which, virtual_button, InputAction::SHOOT)) { + activateCurrent(); + } else if (buttonMatchesAction(which, virtual_button, InputAction::THRUST)) { + popPage(); + } + } + held = NOW; + } + + auto ServiceMenu::handleGamepadAxis(const SDL_Event& event) -> bool { + const auto AXIS = static_cast(event.gaxis.axis); + const Sint16 VAL = event.gaxis.value; + switch (AXIS) { + case SDL_GAMEPAD_AXIS_LEFTX: + processStickX(VAL); + return true; + case SDL_GAMEPAD_AXIS_LEFTY: + processStickY(VAL); + return true; + // Triggers L2/R2: SDL3 nomes emet AXIS_MOTION, no button events. + // Per poder rebindar FIRE/ACCEL als triggers, sintetitzem aqui + // la pulsacio amb edge-detect i la passem pel mateix flux. + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: + processTriggerEdge(event.gaxis.which, VAL, Input::TRIGGER_L2_AS_BUTTON, trigger_l2_held_); + return true; + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: + processTriggerEdge(event.gaxis.which, VAL, Input::TRIGGER_R2_AS_BUTTON, trigger_r2_held_); + return true; + default: + return false; + } + } + auto ServiceMenu::computeTargetHeight() const -> float { if (stack_.empty()) { return 0.0F; diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index 8b4b732..61f7465 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -85,19 +85,39 @@ namespace System { void update(float delta_time); void draw() const; - // Processa el KEY_DOWN. Retorna true si l'ha consumit (UP/DOWN/ENTER/ - // RIGHT/BACKSPACE/LEFT mentre esta obert). false en qualsevol altre cas. + // Processa events de navegacio. Retorna true si l'event s'ha consumit. + // Accepta: + // - SDL_EVENT_KEY_DOWN: UP/DOWN/ENTER/RIGHT/LEFT/BACKSPACE. + // - SDL_EVENT_GAMEPAD_BUTTON_DOWN: DPAD per nav, FIRE = ENTER, + // ACCELERATE = BACK. La resta de botons s'ignoren. + // - SDL_EVENT_GAMEPAD_AXIS_MOTION: stick X/Y amb edge-detect. auto handleEvent(const SDL_Event& event) -> bool; private: ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay); + // Sub-handlers de handleEvent. Privats, no son part de l'API publica. + auto handleKeyDown(const SDL_Event& event) -> bool; + auto handleGamepadButton(const SDL_Event& event) -> bool; + auto handleGamepadAxis(const SDL_Event& event) -> bool; + // Helpers per a cada eix; permeten que handleGamepadAxis es quedi + // com a dispatcher i no bote el llindar de complexitat. + void processStickX(Sint16 val); + void processStickY(Sint16 val); + void processTriggerEdge(SDL_JoystickID which, Sint16 val, int virtual_button, bool& held); + void buildRootPage(); [[nodiscard]] auto buildVideoPage() -> Page; [[nodiscard]] auto buildResolutionPage() const -> Page; [[nodiscard]] static auto buildAudioPage() -> Page; [[nodiscard]] auto buildOptionsPage() const -> Page; [[nodiscard]] auto buildSystemPage() -> Page; + [[nodiscard]] auto buildControlsPage() -> Page; + // Llista de mandos detectats per a un jugador. Cada item assigna el + // pad triat (amb swap automatic si l'altre jugador ja el tenia) i + // tanca la picker amb popPage. L'ultim item es "SENSE MANDO" per a + // desasignar. + [[nodiscard]] auto buildPadPickerPage(int player_index) -> Page; // Pagina de confirmacio "ESTAS SEGUR? NO/SI". on_yes s'executa si // l'usuari selecciona SI; el cursor per defecte apunta a NO. void pushConfirmPage(const std::string& title_key, std::function on_yes); @@ -139,6 +159,20 @@ namespace System { float highlight_h_ = 0.0F; bool highlight_snap_ = true; + // Edge-detect de stick analogic per a navegacio. Una sola activacio + // per direccio: cal tornar a centre (sota el llindar) per disparar + // una altra. Compartit entre tots els pads — qualsevol jugador pot + // navegar el menu. + bool stick_left_held_ = false; + bool stick_right_held_ = false; + bool stick_up_held_ = false; + bool stick_down_held_ = false; + // Edge-detect dels triggers L2/R2 com a botons virtuals. SDL3 no + // emet button events per als triggers; els llegim com a axis i + // sintetitzem una pulsacio quan creuen el llindar. + bool trigger_l2_held_ = false; + bool trigger_r2_held_ = false; + static std::unique_ptr instance; }; diff --git a/source/core/utils/string_utils.hpp b/source/core/utils/string_utils.hpp new file mode 100644 index 0000000..157a963 --- /dev/null +++ b/source/core/utils/string_utils.hpp @@ -0,0 +1,23 @@ +// string_utils.hpp - Utilitats genèriques de cadenes +// © 2026 JailDesigner +// +// VectorText només admet ASCII en majúscules; les notificacions, el menú +// de servei i l'overlay de rebind passen els textos dinàmics per aquest +// helper abans de pintar-los. + +#pragma once + +#include +#include + +namespace Utils { + + inline auto toUpperAscii(const std::string& s) -> std::string { + std::string result = s; + for (char& c : result) { + c = static_cast(std::toupper(static_cast(c))); + } + return result; + } + +} // namespace Utils diff --git a/source/game/config_yaml.cpp b/source/game/config_yaml.cpp index 4c1d9ce..43a821a 100644 --- a/source/game/config_yaml.cpp +++ b/source/game/config_yaml.cpp @@ -373,12 +373,21 @@ namespace ConfigYaml { if (gp.contains("button_shoot")) { player1.gamepad.button_shoot = stringToButton(gp["button_shoot"].get_value()); } + if (gp.contains("button_start")) { + player1.gamepad.button_start = stringToButton(gp["button_start"].get_value()); + } + if (gp.contains("button_menu")) { + player1.gamepad.button_menu = stringToButton(gp["button_menu"].get_value()); + } } - // Carregar nom del gamepad + // Carregar nom i path del gamepad assignat if (p1.contains("gamepad_name")) { player1.gamepad_name = p1["gamepad_name"].get_value(); } + if (p1.contains("gamepad_path")) { + player1.gamepad_path = p1["gamepad_path"].get_value(); + } } // Carregar controls del player 2 desde YAML @@ -421,12 +430,21 @@ namespace ConfigYaml { if (gp.contains("button_shoot")) { player2.gamepad.button_shoot = stringToButton(gp["button_shoot"].get_value()); } + if (gp.contains("button_start")) { + player2.gamepad.button_start = stringToButton(gp["button_start"].get_value()); + } + if (gp.contains("button_menu")) { + player2.gamepad.button_menu = stringToButton(gp["button_menu"].get_value()); + } } - // Carregar nom del gamepad + // Carregar nom i path del gamepad assignat if (p2.contains("gamepad_name")) { player2.gamepad_name = p2["gamepad_name"].get_value(); } + if (p2.contains("gamepad_path")) { + player2.gamepad_path = p2["gamepad_path"].get_value(); + } } // Carregar configuración des del file YAML @@ -531,7 +549,10 @@ namespace ConfigYaml { file << " button_right: " << buttonToString(player1.gamepad.button_right) << "\n"; file << " button_thrust: " << buttonToString(player1.gamepad.button_thrust) << "\n"; file << " button_shoot: " << buttonToString(player1.gamepad.button_shoot) << "\n"; - file << " gamepad_name: \"" << player1.gamepad_name << "\" # Buit = primer disponible\n\n"; + file << " button_start: " << buttonToString(player1.gamepad.button_start) << "\n"; + file << " button_menu: " << buttonToString(player1.gamepad.button_menu) << "\n"; + file << " gamepad_name: \"" << player1.gamepad_name << "\" # Buit = primer disponible\n"; + file << " gamepad_path: \"" << player1.gamepad_path << "\" # Prioritari sobre name\n\n"; } // Guardar controls del player 2 a YAML @@ -548,7 +569,10 @@ namespace ConfigYaml { file << " button_right: " << buttonToString(player2.gamepad.button_right) << "\n"; file << " button_thrust: " << buttonToString(player2.gamepad.button_thrust) << "\n"; file << " button_shoot: " << buttonToString(player2.gamepad.button_shoot) << "\n"; - file << " gamepad_name: \"" << player2.gamepad_name << "\" # Buit = segon disponible\n\n"; + file << " button_start: " << buttonToString(player2.gamepad.button_start) << "\n"; + file << " button_menu: " << buttonToString(player2.gamepad.button_menu) << "\n"; + file << " gamepad_name: \"" << player2.gamepad_name << "\" # Buit = segon disponible\n"; + file << " gamepad_path: \"" << player2.gamepad_path << "\" # Prioritari sobre name\n\n"; } // Guardar configuración al file YAML diff --git a/source/game/config_yaml.hpp b/source/game/config_yaml.hpp index 79ae832..27e00b4 100644 --- a/source/game/config_yaml.hpp +++ b/source/game/config_yaml.hpp @@ -28,6 +28,7 @@ namespace ConfigYaml { .key_start = SDL_SCANCODE_2, }, .gamepad_name = "", + .gamepad_path = "", }, }; diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 6e57169..520bfb0 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -13,11 +13,13 @@ #include "core/audio/audio.hpp" #include "core/defaults.hpp" #include "core/graphics/shape_loader.hpp" +#include "core/input/define_inputs.hpp" #include "core/input/input.hpp" #include "core/locale/locale.hpp" #include "core/math/easing.hpp" #include "core/rendering/shape_renderer.hpp" #include "core/system/scene_context.hpp" +#include "core/system/service_menu.hpp" #include "project.h" using SceneManager::SceneContext; @@ -324,8 +326,20 @@ void TitleScene::update(float delta_time) { break; } - handleSkipInput(); - handleStartInput(); + // Les animacions segueixen pero els inputs es bloquegen mentre el menu + // de servei o l'overlay de redefinicio estiguin actius: en cas contrari, + // SDL_GetKeyboardState i SDL_GetGamepadButton segueixen veient les tecles + // pulsades i podrien disparar handleSkipInput/handleStartInput sense + // intencio. Mateixa logica: per a GameScene tota la pausa es global, + // pero a TitleScene nomes guardem els polls d'input. + const auto* menu = System::ServiceMenu::get(); + const auto* di = System::DefineInputs::get(); + const bool INPUT_BLOCKED = (menu != nullptr && menu->isOpen()) || + (di != nullptr && di->isActive()); + if (!INPUT_BLOCKED) { + handleSkipInput(); + handleStartInput(); + } } void TitleScene::updateStarfieldFadeInState(float delta_time) {