diff --git a/CMakeLists.txt b/CMakeLists.txt index f7f8298..86680a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,8 @@ set(APP_SOURCES source/core/input/global_inputs.cpp source/core/input/input_state.cpp source/core/input/gamepad_manager.cpp + source/core/input/gamepad_bindings.cpp + source/core/input/define_buttons.cpp source/core/input/mouse.cpp source/core/rendering/screen.cpp source/core/rendering/canvas.cpp diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 36c9066..d4d6b4b 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -71,8 +71,13 @@ menu: move_down: "Avall" move_left: "Esquerra" move_right: "Dreta" + jump: "Saltar" + attack: "Atacar" + service: "Opcions" gamepad_p1: "Comandament P1" gamepad_p2: "Comandament P2" + define_buttons_p1: "Definir botons P1" + define_buttons_p2: "Definir botons P2" zoom: "Zoom" screen: "Pantalla completa" vsync: "VSync" @@ -104,3 +109,7 @@ menu: render_info_bottom: "Baix" gamepad_none: "Cap" gamepad_disconnected: "Desconnectat" + press_key: "Polsa una tecla" + press_button: "Polsa un botó" + define_buttons_complete: "Configuració completa" + define_buttons_no_pad: "Cap mando assignat" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index b0e1469..f9e123e 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -57,6 +57,7 @@ menu: system: "System" labels: player_1: "Player 1" + player_2: "Player 2" gamepad_unavailable: "Not available" items: video_submenu: "Video" @@ -69,6 +70,13 @@ menu: move_down: "Down" move_left: "Left" move_right: "Right" + jump: "Jump" + attack: "Attack" + service: "Options" + gamepad_p1: "Gamepad P1" + gamepad_p2: "Gamepad P2" + define_buttons_p1: "Define buttons P1" + define_buttons_p2: "Define buttons P2" zoom: "Zoom" screen: "Fullscreen" vsync: "VSync" @@ -98,3 +106,9 @@ menu: render_info_off: "Off" render_info_top: "Top" render_info_bottom: "Bottom" + gamepad_none: "None" + gamepad_disconnected: "Disconnected" + press_key: "Press a key" + press_button: "Press a button" + define_buttons_complete: "Configuration complete" + define_buttons_no_pad: "No gamepad assigned" diff --git a/source/core/input/define_buttons.cpp b/source/core/input/define_buttons.cpp new file mode 100644 index 0000000..4914b0c --- /dev/null +++ b/source/core/input/define_buttons.cpp @@ -0,0 +1,238 @@ +#include "core/input/define_buttons.hpp" + +#include +#include + +#include "core/locale/locale.hpp" +#include "core/rendering/screen.hpp" +#include "core/rendering/text.hpp" +#include "core/ui/ui_config.hpp" +#include "utils/defines.hpp" + +namespace { + + // Threshold per als triggers L2/R2 — equivalent a CC (16384 ≈ 50%). + constexpr Sint16 TRIGGER_THRESHOLD = 16384; + + // Geometria de l'overlay (canvas lògic 320×240). + constexpr int BOX_WIDTH = 240; + constexpr int PAD_Y = 8; + constexpr int LINE_GAP = 4; + constexpr Uint8 BG_ALPHA = 230; + +} // namespace + +void DefineButtons::init(std::shared_ptr title_font, + std::shared_ptr items_font, + const Palette& ui_palette) { + title_font_ = std::move(title_font); + items_font_ = std::move(items_font); + ui_palette_ = ui_palette; +} + +auto DefineButtons::enable(SDL_Gamepad* pad, std::string name, std::string path) -> bool { + if (pad == nullptr) { return false; } + pad_ = pad; + pad_name_ = std::move(name); + pad_path_ = std::move(path); + enabled_ = true; + finished_ = false; + closing_ = false; + message_shown_ = false; + message_timer_ms_ = 0; + l2_was_pressed_ = false; + r2_was_pressed_ = false; + resetSequence(); + return true; +} + +void DefineButtons::disable() { + enabled_ = false; + finished_ = false; + closing_ = false; + message_shown_ = false; + message_timer_ms_ = 0; + l2_was_pressed_ = false; + r2_was_pressed_ = false; + pad_ = nullptr; + pad_name_.clear(); + pad_path_.clear(); + sequence_.clear(); + index_ = 0; +} + +void DefineButtons::resetSequence() { + // Ordre: ATTACK, JUMP, SERVICE — replica l'estructura de + // `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada + // d'accions amb la SERVICE al final) adaptada al joc actual. La label + // de SERVICE va a una clau pròpia (`menu.items.service`) per no + // reutilitzar la del submenú dels mandos. + sequence_.clear(); + sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); + sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); + sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); + index_ = 0; +} + +auto DefineButtons::isInUse(int button) const -> bool { + return std::ranges::any_of(sequence_, [button](const ButtonAssignment& a) { + return a.button == button; + }); +} + +auto DefineButtons::isReadyToClose() const -> bool { + // Llest per tancar quan: la seqüència ha acabat, ja s'ha mostrat el + // missatge, està en mode tancament, i el timer ha expirat. Replica el + // patró de `coffee_crisis_arcade_edition::DefineButtons::isReadyToClose`, + // simplificat perquè ací no tenim animació de WindowMessage. + return finished_ && message_shown_ && closing_ && message_timer_ms_ <= 0; +} + +void DefineButtons::update(int delta_ms) { + if (!enabled_) { return; } + if (finished_ && message_shown_) { + message_timer_ms_ -= delta_ms; + if (message_timer_ms_ <= 0 && !closing_) { + closing_ = true; + message_timer_ms_ = 0; // anchor a 0 per a `isReadyToClose`. + } + } +} + +void DefineButtons::handleEvent(const SDL_Event& ev) { + if (!enabled_) { return; } + switch (ev.type) { + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + handleButtonDown(ev.gbutton); + break; + case SDL_EVENT_GAMEPAD_BUTTON_UP: + onButtonUp(); + break; + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + handleAxisMotion(ev.gaxis); + break; + default: + break; + } +} + +void DefineButtons::assignAndAdvance(int button) { + if (finished_ || isInUse(button)) { return; } + sequence_.at(index_).button = button; + if (index_ + 1 < sequence_.size()) { + ++index_; + } else { + finished_ = true; + } +} + +void DefineButtons::processTrigger(int virtual_button, bool& was_pressed, bool now) { + if (now && !was_pressed) { assignAndAdvance(virtual_button); } + if (!now && was_pressed) { onButtonUp(); } + was_pressed = now; +} + +void DefineButtons::handleButtonDown(const SDL_GamepadButtonEvent& ev) { + // Filtra: només acceptem events del mando que estem configurant. + // (Per l'instance_id no podem comparar directament; obtenim el handle + // i el comparem.) + const SDL_Gamepad* source = SDL_GetGamepadFromID(ev.which); + if (source != pad_) { return; } + assignAndAdvance(static_cast(ev.button)); +} + +void DefineButtons::handleAxisMotion(const SDL_GamepadAxisEvent& ev) { + const SDL_Gamepad* source = SDL_GetGamepadFromID(ev.which); + if (source != pad_) { return; } + + // Triggers L2/R2 com a "botons" amb edge-detect (transició inactiu → + // actiu). Replica `doControllerAxisMotion` de + // coffee_crisis_arcade_edition. + const bool NOW = ev.value > TRIGGER_THRESHOLD; + if (ev.axis == SDL_GAMEPAD_AXIS_LEFT_TRIGGER) { + processTrigger(GamepadBindings::TRIGGER_L2_AS_BUTTON, l2_was_pressed_, NOW); + } else if (ev.axis == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) { + processTrigger(GamepadBindings::TRIGGER_R2_AS_BUTTON, r2_was_pressed_, NOW); + } +} + +void DefineButtons::onButtonUp() { + if (!finished_ || message_shown_) { return; } + persistAndShowComplete(); +} + +void DefineButtons::persistAndShowComplete() { + std::unordered_map mapping; + for (const auto& a : sequence_) { + mapping[a.action] = a.button; + } + GamepadBindings::get().setBindings(pad_name_, pad_path_, mapping); + message_shown_ = true; + message_timer_ms_ = MESSAGE_DISPLAY_DURATION_MS; +} + +void DefineButtons::render() const { + if (!enabled_ || !title_font_ || !items_font_) { return; } + + const int TITLE_H = title_font_->getLineHeight(); + const int ITEM_H = items_font_->getLineHeight(); + const int TITLE_KERNING = title_font_->getDefaultKerning(); + const int ITEM_KERNING = items_font_->getDefaultKerning(); + + // Calcular l'alçada total: títol + 2 línies (instrucció + label). + int box_h = (PAD_Y * 2) + TITLE_H + LINE_GAP + (ITEM_H * 2) + LINE_GAP; + int box_x = (GameCanvas::WIDTH - BOX_WIDTH) / 2; + int box_y = (GameCanvas::HEIGHT - box_h) / 2; + + const auto BG_ARGB = ui_palette_[UiConfig::get().menuBg()]; + const auto BORDER_ARGB = ui_palette_[UiConfig::get().menuBorder()]; + Screen::get().blendRect(box_x, box_y, BOX_WIDTH, box_h, BG_ARGB, BG_ALPHA); + Screen::get().fillRect(box_x, box_y, BOX_WIDTH, 1, BORDER_ARGB); + Screen::get().fillRect(box_x, box_y + box_h - 1, BOX_WIDTH, 1, BORDER_ARGB); + Screen::get().fillRect(box_x, box_y, 1, box_h, BORDER_ARGB); + Screen::get().fillRect(box_x + BOX_WIDTH - 1, box_y, 1, box_h, BORDER_ARGB); + + int cursor_y = box_y + PAD_Y; + const int CENTER_X = box_x + (BOX_WIDTH / 2); + + // Títol: nom del mando. + const std::string TITLE = pad_name_; + title_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + TITLE, + TITLE_KERNING, + UiConfig::get().menuTitle()); + cursor_y += TITLE_H + LINE_GAP; + + if (message_shown_) { + // Pantalla final: missatge de configuració completa. + items_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + Locale::get().text("menu.values.define_buttons_complete"), + ITEM_KERNING, + UiConfig::get().menuValue()); + return; + } + + // Instrucció + acció pendent. Replica visualment el WindowMessage de + // coffee_crisis_arcade_edition. + items_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + Locale::get().text("menu.values.press_button"), + ITEM_KERNING, + UiConfig::get().menuLabel()); + cursor_y += ITEM_H + LINE_GAP; + + if (index_ < sequence_.size()) { + const auto& current = sequence_[index_]; + items_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + Locale::get().text(current.label_key), + ITEM_KERNING, + UiConfig::get().menuCursor()); + } +} diff --git a/source/core/input/define_buttons.hpp b/source/core/input/define_buttons.hpp new file mode 100644 index 0000000..a3428e4 --- /dev/null +++ b/source/core/input/define_buttons.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "core/input/gamepad_bindings.hpp" +#include "core/rendering/surface.hpp" // Palette + +class Text; + +// Sub-mòdul de captura seqüencial de botons per a un mando concret. +// Replica `DefineButtons` de coffee_crisis_arcade_edition: l'usuari obre el +// menú de servei, escull "Definir botons P1" / "P2", i el sub-mòdul li +// demana un per un els botons per a cada acció (JUMP → ATTACK → SERVICE). +// +// Cicle de vida: +// 1. `enable(pad, name, path)`: arrenca la seqüència. `index_` = 0. +// 2. Cada SDL_EVENT_GAMEPAD_BUTTON_DOWN (o transició de L2/R2 via +// AXIS_MOTION) que NO duplique cap botó ja assignat dins la sessió +// avança `index_`. Si `index_` arriba a la mida de la seqüència, +// `finished_` es posa a true i, al pròxim BUTTON_UP, escrivim el mapeig +// complet a `GamepadBindings`, mostrem "Configuració completa" durant +// `MESSAGE_DISPLAY_DURATION_MS`, i posem `closing_`. Quan tot l'overlay +// ja no és visible, `isReadyToClose()` torna `true` i el caller ens +// fa `disable()`. +// +// El layer runtime de l'aplicació viu a `GamepadBindings`: aquesta classe +// només decideix què s'escriu i quan. +class DefineButtons { + public: + DefineButtons() = default; + ~DefineButtons() = default; + DefineButtons(const DefineButtons&) = delete; + DefineButtons(DefineButtons&&) = delete; + auto operator=(const DefineButtons&) -> DefineButtons& = delete; + auto operator=(DefineButtons&&) -> DefineButtons& = delete; + + // Injecta les fonts i la paleta UI; cridat des de `ServiceMenu::init` + // perquè comparteixin tipografia i colors de tema. + void init(std::shared_ptr title_font, + std::shared_ptr items_font, + const Palette& ui_palette); + + // Arranca la sessió per al mando indicat. `pad` ha de ser un handle + // viu obtingut de `GamepadManager`. Retorna false si `pad` és nullptr. + auto enable(SDL_Gamepad* pad, std::string name, std::string path) -> bool; + + // Atura la sessió (sense persistir) i neteja l'estat. + void disable(); + + [[nodiscard]] auto isEnabled() const -> bool { return enabled_; } + [[nodiscard]] auto isReadyToClose() const -> bool; + + // Avança el timer de tancament (ms) — replica el `update` de + // coffee_crisis_arcade_edition. No fa res si no està actiu. + void update(int delta_ms); + + // Pinta l'overlay sobre el canvas actual (centrat). No fa res si no + // està actiu. + void render() const; + + // Punt d'entrada d'events: BUTTON_DOWN / BUTTON_UP / AXIS_MOTION del + // mando objectiu. La resta s'ignoren. + void handleEvent(const SDL_Event& ev); + + private: + static constexpr int MESSAGE_DISPLAY_DURATION_MS = 2000; + + struct ButtonAssignment { + std::string label_key; + GamepadBindings::Action action; + int button; // SDL_GamepadButton o trigger virtual; INVALID = sense assignar. + }; + + // Reinicialitza la seqüència ordenada (JUMP → ATTACK → SERVICE) amb + // tots els botons sense assignar. Cridat a `enable`. + void resetSequence(); + + // Comprova si un botó ja ha estat assignat dins de la sessió actual. + // Replica `checkButtonNotInUse` / `checkTriggerNotInUse`. + [[nodiscard]] auto isInUse(int button) const -> bool; + + void handleButtonDown(const SDL_GamepadButtonEvent& ev); + void handleAxisMotion(const SDL_GamepadAxisEvent& ev); + + // Helper de la lògica comuna a button-down i als triggers L2/R2: + // assigna `button` a l'acció pendent i avança l'índex (o marca + // finished_ si era l'última). No fa res si `button` ja està en ús. + void assignAndAdvance(int button); + + // Tracta una transició de trigger (L2 o R2) com a botó virtual amb + // edge-detect. `virtual_button` és `TRIGGER_L?_AS_BUTTON`, + // `was_pressed` és l'estat anterior (mutat per referència). + void processTrigger(int virtual_button, bool& was_pressed, bool now); + + // Crida `onSequenceEnd()` quan l'usuari deixa anar el botó després + // d'haver completat la seqüència — replica la branca de + // `SDL_EVENT_GAMEPAD_BUTTON_UP` a `DefineButtons::handleEvents`. + void onButtonUp(); + + // Persistix les assignacions a `GamepadBindings` i posa `message_shown_`. + void persistAndShowComplete(); + + SDL_Gamepad* pad_{nullptr}; + std::string pad_name_; + std::string pad_path_; + + std::shared_ptr title_font_; + std::shared_ptr items_font_; + Palette ui_palette_{}; + + std::vector sequence_; + std::size_t index_{0}; + int message_timer_ms_{0}; + + bool enabled_{false}; + bool finished_{false}; + bool closing_{false}; + bool message_shown_{false}; + bool l2_was_pressed_{false}; + bool r2_was_pressed_{false}; +}; diff --git a/source/core/input/gamepad_bindings.cpp b/source/core/input/gamepad_bindings.cpp new file mode 100644 index 0000000..197be1e --- /dev/null +++ b/source/core/input/gamepad_bindings.cpp @@ -0,0 +1,220 @@ +#include "core/input/gamepad_bindings.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "external/fkyaml_node.hpp" + +namespace { + + // Threshold per a considerar L2/R2 com a premuts (mateix valor que + // coffee_crisis_arcade_edition: 16384 ≈ 50% de ±32767). + constexpr Sint16 TRIGGER_THRESHOLD = 16384; + + // Taules de mapejat string ↔ valor: replica BUTTON_TO_STRING / + // STRING_TO_BUTTON / ACTION_TO_STRING / STRING_TO_ACTION del projecte + // de referència. Els noms en lowercase per llegibilitat al YAML. + using ActionPair = std::pair; + constexpr std::array ACTION_NAMES = { + ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}}, + ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}}, + ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}}, + }; + + using ButtonPair = std::pair; + constexpr std::array BUTTON_NAMES = { + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_SOUTH), std::string_view{"south"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_EAST), std::string_view{"east"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_WEST), std::string_view{"west"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_NORTH), std::string_view{"north"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_BACK), std::string_view{"back"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_GUIDE), std::string_view{"guide"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_START), std::string_view{"start"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_LEFT_STICK), std::string_view{"left_stick"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_RIGHT_STICK), std::string_view{"right_stick"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), std::string_view{"l1"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER), std::string_view{"r1"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_DPAD_UP), std::string_view{"dpad_up"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_DPAD_DOWN), std::string_view{"dpad_down"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_DPAD_LEFT), std::string_view{"dpad_left"}}, + ButtonPair{static_cast(SDL_GAMEPAD_BUTTON_DPAD_RIGHT), std::string_view{"dpad_right"}}, + ButtonPair{GamepadBindings::TRIGGER_L2_AS_BUTTON, std::string_view{"l2"}}, + ButtonPair{GamepadBindings::TRIGGER_R2_AS_BUTTON, std::string_view{"r2"}}, + }; + + auto actionToString(GamepadBindings::Action a) -> std::string_view { + for (const auto& [act, name] : ACTION_NAMES) { + if (act == a) { return name; } + } + return ACTION_NAMES.front().second; + } + + auto stringToAction(std::string_view s, GamepadBindings::Action& out) -> bool { + for (const auto& [act, name] : ACTION_NAMES) { + if (name == s) { + out = act; + return true; + } + } + return false; + } + + auto buttonToString(int btn) -> std::string_view { + for (const auto& [val, name] : BUTTON_NAMES) { + if (val == btn) { return name; } + } + return std::string_view{""}; + } + + auto stringToButton(std::string_view s, int& out) -> bool { + for (const auto& [val, name] : BUTTON_NAMES) { + if (name == s) { + out = val; + return true; + } + } + return false; + } + +} // namespace + +auto GamepadBindings::get() -> GamepadBindings& { + static GamepadBindings instance_; + return instance_; +} + +auto GamepadBindings::defaultButton(Action action) -> int { + // Defaults històrics de l'aee_2026 (vegeu Player::accumulateGamepadMovement + // i les consultes del Director per al menú). + switch (action) { + case Action::JUMP: + return static_cast(SDL_GAMEPAD_BUTTON_SOUTH); + case Action::ATTACK: + return static_cast(SDL_GAMEPAD_BUTTON_WEST); + case Action::SERVICE: + return static_cast(SDL_GAMEPAD_BUTTON_BACK); + } + return static_cast(SDL_GAMEPAD_BUTTON_INVALID); +} + +auto GamepadBindings::findConfig(const std::string& name, const std::string& path) const -> int { + if (!path.empty()) { + for (std::size_t i = 0; i < configs_.size(); ++i) { + if (configs_[i].path == path) { return static_cast(i); } + } + } + if (!name.empty()) { + for (std::size_t i = 0; i < configs_.size(); ++i) { + if (configs_[i].name == name) { return static_cast(i); } + } + } + return -1; +} + +auto GamepadBindings::getButton(const std::string& name, const std::string& path, Action action) const -> int { + const int IDX = findConfig(name, path); + if (IDX < 0) { return defaultButton(action); } + const auto& m = configs_[static_cast(IDX)].bindings; + auto it = m.find(action); + return (it != m.end()) ? it->second : defaultButton(action); +} + +void GamepadBindings::setBindings(const std::string& name, + const std::string& path, + const std::unordered_map& bindings) { + int idx = findConfig(name, path); + if (idx < 0) { + configs_.push_back(Config{.name = name, .path = path, .bindings = bindings}); + } else { + auto& cfg = configs_[static_cast(idx)]; + cfg.name = name; + cfg.path = path; + cfg.bindings = bindings; + } + save(); +} + +auto GamepadBindings::isPressed(SDL_Gamepad* pad, int button) -> bool { + if (pad == nullptr) { return false; } + if (button == TRIGGER_L2_AS_BUTTON) { + return SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > TRIGGER_THRESHOLD; + } + if (button == TRIGGER_R2_AS_BUTTON) { + return SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > TRIGGER_THRESHOLD; + } + return SDL_GetGamepadButton(pad, static_cast(button)); +} + +void GamepadBindings::load(const std::string& file_path) { + file_path_ = file_path; + configs_.clear(); + + std::ifstream file(file_path); + if (!file) { return; } + std::stringstream buf; + buf << file.rdbuf(); + const std::string CONTENT = buf.str(); + if (CONTENT.empty()) { return; } + + try { + auto yaml = fkyaml::node::deserialize(CONTENT); + if (!yaml.contains("gamepads") || !yaml["gamepads"].is_sequence()) { + std::cerr << "GamepadBindings: secció 'gamepads' absent o invàlida a " + << file_path << '\n'; + return; + } + for (const auto& entry : yaml["gamepads"]) { + Config cfg; + if (entry.contains("name")) { cfg.name = entry["name"].get_value(); } + if (entry.contains("path")) { cfg.path = entry["path"].get_value(); } + if (entry.contains("bindings") && entry["bindings"].is_mapping()) { + for (const auto& kv : entry["bindings"].as_map()) { + const auto KEY = kv.first.get_value(); + const auto VAL = kv.second.get_value(); + Action action{}; + int btn = 0; + if (stringToAction(KEY, action) && stringToButton(VAL, btn)) { + cfg.bindings[action] = btn; + } + } + } + configs_.push_back(std::move(cfg)); + } + std::cout << "GamepadBindings: " << configs_.size() << " mandos carregats de " + << file_path << '\n'; + } catch (const std::exception& e) { + std::cerr << "GamepadBindings: error parsejant " << file_path << ": " + << e.what() << '\n'; + } +} + +auto GamepadBindings::save() -> bool { + if (file_path_.empty()) { return false; } + + std::ostringstream out; + out << "# AEE 2026 - Vinculacions de mando per dispositiu.\n"; + out << "# Auto-generat des del menú de servei. Editable a mà.\n"; + out << "gamepads:\n"; + for (const auto& cfg : configs_) { + out << " - name: \"" << cfg.name << "\"\n"; + out << " path: \"" << cfg.path << "\"\n"; + out << " bindings:\n"; + for (const auto& [action, button] : cfg.bindings) { + out << " " << actionToString(action) << ": \"" + << buttonToString(button) << "\"\n"; + } + } + + std::ofstream file(file_path_, std::ios::binary); + if (!file) { + std::cerr << "GamepadBindings: no es pot escriure " << file_path_ << '\n'; + return false; + } + file << out.str(); + return file.good(); +} diff --git a/source/core/input/gamepad_bindings.hpp b/source/core/input/gamepad_bindings.hpp new file mode 100644 index 0000000..3136266 --- /dev/null +++ b/source/core/input/gamepad_bindings.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include + +#include +#include +#include +#include + +// Vinculacions remapeables d'accions del joc a botons d'un mando concret, +// indexades per `(name, path)` del mando — replica el patró +// `GamepadConfig` + `GamepadConfigManager` de coffee_crisis_arcade_edition. +// +// Persisteix en un YAML aïllat al system folder (no a `config.yaml`) perquè +// és per dispositiu, no per usuari/perfil: dos mandos diferents poden +// tenir mappings diferents i cap d'ells va lligat a un slot de jugador. +// +// El layer runtime (consultat per Player/Director cada frame) és +// directament aquesta classe; no hi ha caché a `GamepadManager::Entry`. El +// nombre d'entries persistides és típicament <10, i les accions són 3 +// (JUMP/ATTACK/SERVICE), així que les consultes són trivialment ràpides. +class GamepadBindings { + public: + enum class Action : std::uint8_t { + JUMP, + ATTACK, + SERVICE, + }; + + // Triggers L2/R2 representats com a "botons virtuals" — replica la + // convenció de coffee_crisis_arcade_edition (`TRIGGER_L2_AS_BUTTON`, + // `TRIGGER_R2_AS_BUTTON`). El threshold viu a `isPressed`. + static constexpr int TRIGGER_L2_AS_BUTTON = 100; + static constexpr int TRIGGER_R2_AS_BUTTON = 101; + + static auto get() -> GamepadBindings&; + + GamepadBindings(const GamepadBindings&) = delete; + GamepadBindings(GamepadBindings&&) = delete; + auto operator=(const GamepadBindings&) -> GamepadBindings& = delete; + auto operator=(GamepadBindings&&) -> GamepadBindings& = delete; + + // Fixa la ruta del fitxer persistent + el carrega. Si no existix + // encara, l'estat queda buit (les consultes retornaran defaults) i + // `save()` el crearà al primer `setBindings`. + void load(const std::string& file_path); + + // Persistix l'estat actual (totes les Config conegudes). Cridat + // automàticament dins de `setBindings` — no cal cridar-la a mà. + auto save() -> bool; + + // Retorna el botó (o trigger virtual 100/101) lligat a `action` per al + // mando identificat per (name, path). Si no hi ha entrada per aquest + // mando, retorna el default global. La cerca és primer per `path` + // (re-vincular el mateix mando al mateix port és prioritari) i, si no + // troba, fallback per `name` (mando connectat a un altre port USB). + [[nodiscard]] auto getButton(const std::string& name, const std::string& path, Action action) const -> int; + + // Reescriu totes les vinculacions del mando (name, path). Si no hi + // havia config per aquest dispositiu, en crea una nova. Persisteix a + // disc immediatament. Replicació de `Input::saveGamepadConfigFromGamepad`. + void setBindings(const std::string& name, + const std::string& path, + const std::unordered_map& bindings); + + // Wrapper de polling: torna `true` si el "botó" (o trigger virtual) + // està premut ara mateix al mando `pad`. Encapsula el threshold per + // a L2/R2 (~50% del rang). + [[nodiscard]] static auto isPressed(SDL_Gamepad* pad, int button) -> bool; + + // Default de fàbrica per a una acció — usat tant pel fallback de + // `getButton` com per inicialitzar la seqüència del DefineButtons. + [[nodiscard]] static auto defaultButton(Action action) -> int; + + private: + struct Config { + std::string name; + std::string path; + std::unordered_map bindings; + }; + + GamepadBindings() = default; + ~GamepadBindings() = default; + + // Localitza la Config que coincideix amb (name, path); retorna -1 si + // no la troba. Match prioritari per path, fallback per nom. + [[nodiscard]] auto findConfig(const std::string& name, const std::string& path) const -> int; + + std::vector configs_; + std::string file_path_; +}; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 9ebc48d..13b84f6 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -13,6 +13,7 @@ #include "core/audio/audio.hpp" #include "core/audio/sound_effects_config.hpp" +#include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/key_config.hpp" #include "core/input/mouse.hpp" @@ -82,6 +83,11 @@ Director::Director() { std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n'; } + // Vinculacions de mando per dispositiu — fitxer aïllat de `config.yaml` + // perquè és per (name, path) del mando, no per perfil/usuari. Replica + // el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`). + GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml"); + // Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix // es crea amb presets per defecte (off, crt, ntsc). Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE); @@ -296,12 +302,29 @@ void Director::processEvents() { if (QUIT_REQUESTED) { current_scene_.reset(); } } +namespace { + + // Resol el botó SERVICE configurat per al mando origen d'aquest event. + // Retorna -1 si no en troba cap. La cerca passa per `GamepadManager` + // per obtenir el (name, path) i d'ahí a `GamepadBindings`. + auto serviceButtonForGamepadEvent(SDL_JoystickID jid) -> int { + for (const auto& info : GamepadManager::get().getAll()) { + if (info.pad == nullptr) { continue; } + if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; } + return GamepadBindings::get().getButton(info.name, info.path, GamepadBindings::Action::SERVICE); + } + return -1; + } + +} // namespace + void Director::processGamepadEvents() { // Dues responsabilitats: (1) passar ADDED/REMOVED al GamepadManager // perquè obri/tanqui mandos i emeti les notificacions hot-plug; (2) - // tractar el botó BACK com a toggle del menú quan està tancat (el - // paral·lel de F9 per al teclat). Quan el menú està obert, el BACK - // passa pel pipeline del `ServiceMenu::handleEvent()` via + // tractar el botó SERVICE (per defecte BACK; remapejable per + // `GamepadBindings`) com a toggle del menú quan està tancat (el + // paral·lel de F9 per al teclat). Quan el menú està obert, el botó + // SERVICE passa pel pipeline del `ServiceMenu::handleEvent()` via // `processMenuInput()` i allà es converteix en `close()`. for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { bool consumed = false; @@ -309,10 +332,12 @@ void Director::processGamepadEvents() { GamepadManager::get().handleEvent(*it); consumed = true; } else if (it->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && - it->gbutton.button == SDL_GAMEPAD_BUTTON_BACK && !ServiceMenu::get().isOpen()) { - ServiceMenu::get().toggle(); - consumed = true; + const int SERVICE_BTN = serviceButtonForGamepadEvent(it->gbutton.which); + if (SERVICE_BTN >= 0 && static_cast(it->gbutton.button) == SERVICE_BTN) { + ServiceMenu::get().toggle(); + consumed = true; + } } if (consumed) { it = EventBuffer::events.erase(it); diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 59ff6da..1187d49 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -9,6 +9,7 @@ #include #include "core/audio/audio.hpp" +#include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/rendering/screen.hpp" @@ -16,6 +17,7 @@ #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/event_bus.hpp" +#include "game/options.hpp" #include "utils/defaults.hpp" namespace { @@ -578,6 +580,12 @@ void Player::tick(float delta_time) { return; } + // Refresca les vinculacions des d'Options en calent: així un remap fet + // al menú de servei té efecte immediatament, sense esperar al pròxim + // GameScene::onEnter. Replica el comportament d'AEE + // (`KeyRemap::update` que llig `Options::keys_game` cada frame). + refreshControlsFromOptions(); + // 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 @@ -653,6 +661,18 @@ void Player::tick(float delta_time) { } } +void Player::refreshControlsFromOptions() { + if (id_ != 0 && id_ != 1) { return; } + const auto& kb = (id_ == 0) ? Options::controls().keyboard_p1 + : Options::controls().keyboard_p2; + controls_.up = kb.up; + controls_.down = kb.down; + controls_.left = kb.left; + controls_.right = kb.right; + controls_.jump = kb.jump; + controls_.attack = kb.attack; +} + auto Player::consumeJumpEdge() -> bool { // Edge-detection independent per teclat i mando: si qualsevol dels // dos passa de no-premut a premut aquest frame, comptem com a salt. @@ -664,7 +684,11 @@ auto Player::consumeJumpEdge() -> bool { jump_held_keyboard_ = KEY_NOW; SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); - const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH); + const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP); + const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() && + GamepadBindings::isPressed(pad, JUMP_BTN); if (PAD_NOW && !jump_held_gamepad_) { edge = true; } jump_held_gamepad_ = PAD_NOW; @@ -672,16 +696,20 @@ auto Player::consumeJumpEdge() -> bool { } auto Player::consumeAttackEdge() -> bool { - // Mateix patró que `consumeJumpEdge` però per al botó d'atac. Mando: - // SDL_GAMEPAD_BUTTON_WEST (X a Xbox / Quadrat a PlayStation), oposat - // a SOUTH per evitar topades semàntiques. + // Mateix patró que `consumeJumpEdge` però per al botó d'atac. El botó + // físic ve d'`GamepadBindings`, persistit per dispositiu — l'usuari + // l'ha pogut remapejar des del menú de servei. const bool* keys = InputState::getKeyboardState(); const bool KEY_NOW = keys[controls_.attack]; bool edge = (KEY_NOW && !attack_held_keyboard_); attack_held_keyboard_ = KEY_NOW; SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); - const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_WEST); + const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + const int ATK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK); + const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() && + GamepadBindings::isPressed(pad, ATK_BTN); if (PAD_NOW && !attack_held_gamepad_) { edge = true; } attack_held_gamepad_ = PAD_NOW; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 64b328d..d17ca82 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -407,6 +407,12 @@ class Player : public AnimatedSprite { float (Player::*get_pos)() const, void (Player::*set_pos)(float)) -> bool; + // Refresca `controls_` des d'`Options::controls()` cada frame. Replica + // el patró d'AEE on `KeyRemap::update` reflecteix Options::keys_game al + // buffer del teclat cada tick: així un remap fet al menú de servei té + // efecte immediat sense haver de tornar a entrar a la GameScene. + void refreshControlsFromOptions(); + int id_{-1}; Controls controls_; diff --git a/source/game/options.cpp b/source/game/options.cpp index 0f9a301..441a6ed 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -169,11 +169,51 @@ namespace { readField(node, "path", dst.path); } + // Llegeix una tecla del YAML pel nom (com `SDL_GetScancodeName`). Si la + // clau no hi és o és invàlida, deixa `dst` intacte per preservar el + // default. Replica la política d'AEE (`KeyConfig::applyOverrides`). + void readScancodeField(const fkyaml::node& node, const char* key, SDL_Scancode& dst) { + if (!node.contains(key)) { return; } + const auto NAME = node[key].get_value(); + const SDL_Scancode SC = SDL_GetScancodeFromName(NAME.c_str()); + if (SC != SDL_SCANCODE_UNKNOWN) { dst = SC; } + } + + void parseKeyboard(const fkyaml::node& parent, const char* key, Options::Keyboard& dst) { + if (!parent.contains(key)) { return; } + const auto& node = parent[key]; + readScancodeField(node, "up", dst.up); + readScancodeField(node, "down", dst.down); + readScancodeField(node, "left", dst.left); + readScancodeField(node, "right", dst.right); + readScancodeField(node, "jump", dst.jump); + readScancodeField(node, "attack", dst.attack); + } + void parseControls(const fkyaml::node& yaml) { if (!yaml.contains("controls")) { return; } const auto& node = yaml["controls"]; parseGamepadAssignment(node, "gamepad_p1", g_controls.gamepad_p1); parseGamepadAssignment(node, "gamepad_p2", g_controls.gamepad_p2); + parseKeyboard(node, "keyboard_p1", g_controls.keyboard_p1); + parseKeyboard(node, "keyboard_p2", g_controls.keyboard_p2); + } + + // Escriu una tecla com a string del nom SDL ("Up", "W", ...). Si + // l'scancode és UNKNOWN o sense nom registrat, escriu "" — no en farem + // override en càrrega gràcies a `readScancodeField`. + void writeKeyboard(std::ostringstream& out, const char* key, const Options::Keyboard& kb) { + const auto NAME_OR_EMPTY = [](SDL_Scancode sc) -> std::string { + const char* n = SDL_GetScancodeName(sc); + return (n != nullptr) ? std::string{n} : std::string{}; + }; + out << " " << key << ":\n"; + out << " up: \"" << NAME_OR_EMPTY(kb.up) << "\"\n"; + out << " down: \"" << NAME_OR_EMPTY(kb.down) << "\"\n"; + out << " left: \"" << NAME_OR_EMPTY(kb.left) << "\"\n"; + out << " right: \"" << NAME_OR_EMPTY(kb.right) << "\"\n"; + out << " jump: \"" << NAME_OR_EMPTY(kb.jump) << "\"\n"; + out << " attack: \"" << NAME_OR_EMPTY(kb.attack) << "\"\n"; } // Aplica les assignacions persistides al GamepadManager. Ordre garantit @@ -561,6 +601,32 @@ namespace Options { saveToFile(); } + void setKeyboardBinding(int player_id, KeyboardAction action, SDL_Scancode scancode) { + if (player_id < 0 || player_id > 1) { return; } + auto& kb = (player_id == 0) ? g_controls.keyboard_p1 : g_controls.keyboard_p2; + switch (action) { + case KeyboardAction::UP: + kb.up = scancode; + break; + case KeyboardAction::DOWN: + kb.down = scancode; + break; + case KeyboardAction::LEFT: + kb.left = scancode; + break; + case KeyboardAction::RIGHT: + kb.right = scancode; + break; + case KeyboardAction::JUMP: + kb.jump = scancode; + break; + case KeyboardAction::ATTACK: + kb.attack = scancode; + break; + } + saveToFile(); + } + void setConfigFile(const std::string& path) { g_config_file_path = path; } @@ -666,6 +732,8 @@ namespace Options { out << " gamepad_p2:\n"; out << " name: \"" << g_controls.gamepad_p2.name << "\"\n"; out << " path: \"" << g_controls.gamepad_p2.path << "\"\n"; + writeKeyboard(out, "keyboard_p1", g_controls.keyboard_p1); + writeKeyboard(out, "keyboard_p2", g_controls.keyboard_p2); return writeFile(g_config_file_path, out.str()); } diff --git a/source/game/options.hpp b/source/game/options.hpp index 16657fb..1d4bd15 100644 --- a/source/game/options.hpp +++ b/source/game/options.hpp @@ -1,5 +1,8 @@ #pragma once +#include + +#include #include #include @@ -114,9 +117,49 @@ namespace Options { std::string path; }; + // Tecles de joc d'un jugador (vinculacions remapeables des del menú). + // Cada camp és un `SDL_Scancode` literal; els defaults venen de + // `Defaults::KeysGame*` segons el slot (P1 fletxes, P2 WASD). + struct Keyboard { + SDL_Scancode up{SDL_SCANCODE_UNKNOWN}; + SDL_Scancode down{SDL_SCANCODE_UNKNOWN}; + SDL_Scancode left{SDL_SCANCODE_UNKNOWN}; + SDL_Scancode right{SDL_SCANCODE_UNKNOWN}; + SDL_Scancode jump{SDL_SCANCODE_UNKNOWN}; + SDL_Scancode attack{SDL_SCANCODE_UNKNOWN}; + }; + + // Identificadors d'acció per als setters genèrics: una sola funció + // `setKeyboardBinding(player_id, action, scancode)` cobreix les 12 + // possibles vinculacions sense haver de generar 12 setters individuals. + enum class KeyboardAction : std::uint8_t { + UP, + DOWN, + LEFT, + RIGHT, + JUMP, + ATTACK, + }; + struct Controls { GamepadAssignment gamepad_p1{}; GamepadAssignment gamepad_p2{}; + Keyboard keyboard_p1{ + .up = Defaults::KeysGame::UP, + .down = Defaults::KeysGame::DOWN, + .left = Defaults::KeysGame::LEFT, + .right = Defaults::KeysGame::RIGHT, + .jump = Defaults::KeysGame::JUMP, + .attack = Defaults::KeysGame::ATTACK, + }; + Keyboard keyboard_p2{ + .up = Defaults::KeysGameP2::UP, + .down = Defaults::KeysGameP2::DOWN, + .left = Defaults::KeysGameP2::LEFT, + .right = Defaults::KeysGameP2::RIGHT, + .jump = Defaults::KeysGameP2::JUMP, + .attack = Defaults::KeysGameP2::ATTACK, + }; }; // --- Getters de l'estat (read-only) --- @@ -153,6 +196,12 @@ namespace Options { // = desassignació. `player_id`: 0 = P1, 1 = P2. void setGamepadAssignment(int player_id, const std::string& name, const std::string& path); + // Assigna un nou scancode a una acció d'un jugador i persisteix a + // `config.yaml`. `player_id`: 0 = P1, 1 = P2. Sense validació de + // conflictes — replicant la referència AEE, el mateix scancode pot + // quedar associat a múltiples accions si l'usuari ho elegeix. + void setKeyboardBinding(int player_id, KeyboardAction action, SDL_Scancode scancode); + // --- API pública: càrrega/desament de fitxers --- void setConfigFile(const std::string& path); auto loadFromFile() -> bool; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 8134fd8..7df6a0a 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -334,24 +334,27 @@ GameScene::GameScene(Config config) if (p != nullptr) { spawnWhip(*p); } }); - // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player - // escolta les seues pròpies tecles dins del seu `update`, no hi ha - // estat global d'input. + // Controls per jugador: P1 amb fletxes, P2 amb WASD per defecte. Les + // vinculacions actives venen d'`Options::controls()`, que s'inicialitza + // amb els defaults i pot ser sobreescrita pel menú de servei (i + // persistida a `config.yaml`). + const auto& kb_p1 = Options::controls().keyboard_p1; + const auto& kb_p2 = Options::controls().keyboard_p2; const Player::Controls CONTROLS_P1{ - .up = Defaults::KeysGame::UP, - .down = Defaults::KeysGame::DOWN, - .left = Defaults::KeysGame::LEFT, - .right = Defaults::KeysGame::RIGHT, - .jump = Defaults::KeysGame::JUMP, - .attack = Defaults::KeysGame::ATTACK, + .up = kb_p1.up, + .down = kb_p1.down, + .left = kb_p1.left, + .right = kb_p1.right, + .jump = kb_p1.jump, + .attack = kb_p1.attack, }; const Player::Controls CONTROLS_P2{ - .up = Defaults::KeysGameP2::UP, - .down = Defaults::KeysGameP2::DOWN, - .left = Defaults::KeysGameP2::LEFT, - .right = Defaults::KeysGameP2::RIGHT, - .jump = Defaults::KeysGameP2::JUMP, - .attack = Defaults::KeysGameP2::ATTACK, + .up = kb_p2.up, + .down = kb_p2.down, + .left = kb_p2.left, + .right = kb_p2.right, + .jump = kb_p2.jump, + .attack = kb_p2.attack, }; // Spawn lligat a la porta: tots els jugadors apareixen davant del // passadís de sortida. `computeSpawn` deriva les coordenades de diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 7004cc8..d872ac0 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/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/input/key_config.hpp" @@ -230,6 +231,9 @@ void ServiceMenu::init(std::shared_ptr title_font, font_ = std::move(title_font); items_font_ = std::move(items_font); ui_palette_ = ui_palette; + // Comparteix fonts/paleta amb el sub-mòdul de captura de botons — + // aquest pinta el seu propi overlay quan està actiu. + define_buttons_.init(font_, items_font_, ui_palette_); } void ServiceMenu::toggle() { @@ -253,6 +257,7 @@ void ServiceMenu::close() { if (!open_) { return; } open_ = false; closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura. + capturing_ = false; // 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); @@ -266,6 +271,16 @@ auto ServiceMenu::isVisible() const -> bool { } void ServiceMenu::update(int delta_ms) { + // El sub-mòdul de captura de botons té el seu propi cicle (timer post + // assignació). Una vegada `isReadyToClose`, el desactivem perquè el + // menú principal recuperi el control de l'input. + if (define_buttons_.isEnabled()) { + define_buttons_.update(delta_ms); + if (define_buttons_.isReadyToClose()) { + define_buttons_.disable(); + } + } + const float DT = static_cast(delta_ms) / MS_PER_SECOND; if (closing_) { @@ -419,50 +434,125 @@ void ServiceMenu::activateCurrent(int direction) { 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. + // Entra en mode captura: el Director redirigirà el pròxim + // KEY_DOWN cap a `captureKey()`. Només té sentit amb RETURN / + // ENTER / RIGHT (direction +1) — LEFT no fa res, com a la + // resta de Kinds que requereixen confirmació explícita. + if (direction > 0 && item.set_scancode) { capturing_ = true; } break; } } +namespace { + + // Resol el botó SERVICE configurat per al mando que ha emès aquest + // event. -1 si no troba cap match (mando desconegut o desconnectat + // mentrestant). + auto serviceButtonForEvent(SDL_JoystickID jid) -> int { + for (const auto& info : GamepadManager::get().getAll()) { + if (info.pad == nullptr) { continue; } + if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; } + return GamepadBindings::get().getButton(info.name, info.path, GamepadBindings::Action::SERVICE); + } + return -1; + } + +} // namespace + +void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) { + // El botó SERVICE (per defecte BACK; remapejable per dispositiu via + // `GamepadBindings`) tanca el menú sencer. Cada mando pot tenir el + // seu propi mapeig — resolem-lo a partir del joystick instance ID. + const int SERVICE_BTN = serviceButtonForEvent(ev.which); + if (SERVICE_BTN >= 0 && static_cast(ev.button) == SERVICE_BTN) { + close(); + return; + } + switch (ev.button) { + case SDL_GAMEPAD_BUTTON_DPAD_UP: + moveCursor(-1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: + moveCursor(+1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: + activateCurrent(-1); + break; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: + case SDL_GAMEPAD_BUTTON_EAST: + case SDL_GAMEPAD_BUTTON_NORTH: + activateCurrent(+1); + break; + case SDL_GAMEPAD_BUTTON_WEST: + case SDL_GAMEPAD_BUTTON_SOUTH: + popPage(); + break; + default: + break; + } +} + +void ServiceMenu::captureKey(SDL_Scancode sc) { + if (!capturing_) { return; } + // ESC cancel·la la captura sense assignar — replica AEE + // (`Menu::captureKey`). Qualsevol altra tecla queda assignada. + if (sc == SDL_SCANCODE_ESCAPE) { + capturing_ = false; + return; + } + if (stack_.empty()) { + capturing_ = false; + return; + } + const auto& page = topConst(); + if (page.cursor >= page.items.size()) { + capturing_ = false; + return; + } + const auto& item = page.items[page.cursor]; + if (item.kind == Item::Kind::KEY_BIND && item.set_scancode) { + item.set_scancode(sc); + } + capturing_ = false; +} + // --- Input --- auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool { if (!open_) { return false; } + // Mode de captura de botons del mando: redirigeix tots els events + // del gamepad (BUTTON_DOWN/UP, AXIS_MOTION) al sub-mòdul. ESCAPE de + // teclat cancel·la la sessió i tanca el sub-overlay sense persistir. + if (define_buttons_.isEnabled()) { + if (ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat && + ev.key.scancode == SDL_SCANCODE_ESCAPE) { + define_buttons_.disable(); + return true; + } + define_buttons_.handleEvent(ev); + return true; + } + + // Mode de captura per a un KEY_BIND. Mentre dura, el menú consumeix + // TOTS els events: el primer KEY_DOWN s'assigna a l'item seleccionat + // (o ESC cancel·la), i la resta queden bloquejats perquè cap tecla + // s'escapi al joc. Replica el comportament d'AEE + // (`Menu::isCapturing` + `Menu::captureKey`). + if (capturing_) { + if (ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat) { + captureKey(ev.key.scancode); + } + return true; + } + // Botons del gamepad: cruceta per navegar, EAST/NORTH com a OK, // WEST/SOUTH com a BACK de pàgina, BACK tanca el menú sencer. Quan // el menú està obert consumim TOTS els BUTTON_DOWN perquè cap botó // arribi als consumidors de gameplay. Paral·lel al consum total de // KEY_DOWN més avall. if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { - switch (ev.gbutton.button) { - case SDL_GAMEPAD_BUTTON_DPAD_UP: - moveCursor(-1); - break; - case SDL_GAMEPAD_BUTTON_DPAD_DOWN: - moveCursor(+1); - break; - case SDL_GAMEPAD_BUTTON_DPAD_LEFT: - activateCurrent(-1); - break; - case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: - case SDL_GAMEPAD_BUTTON_EAST: - case SDL_GAMEPAD_BUTTON_NORTH: - activateCurrent(+1); - break; - case SDL_GAMEPAD_BUTTON_WEST: - case SDL_GAMEPAD_BUTTON_SOUTH: - popPage(); - break; - case SDL_GAMEPAD_BUTTON_BACK: - close(); - break; - default: - break; - } + handleGamepadButtonDown(ev.gbutton); return true; } @@ -732,37 +822,62 @@ auto ServiceMenu::buildControls() -> Page { return page; } +namespace { + + auto getKeyboardScancode(int player_id, Options::KeyboardAction action) -> SDL_Scancode { + const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1 + : Options::controls().keyboard_p2; + switch (action) { + case Options::KeyboardAction::UP: + return kb.up; + case Options::KeyboardAction::DOWN: + return kb.down; + case Options::KeyboardAction::LEFT: + return kb.left; + case Options::KeyboardAction::RIGHT: + return kb.right; + case Options::KeyboardAction::JUMP: + return kb.jump; + case Options::KeyboardAction::ATTACK: + return kb.attack; + } + return SDL_SCANCODE_UNKNOWN; + } + +} // namespace + +auto ServiceMenu::makeKeyboardItem(const std::string& label_key, int player_id, std::uint8_t action) -> Item { + const auto KA = static_cast(action); + return makeKeyBind( + label_key, + [player_id, KA]() { return getKeyboardScancode(player_id, KA); }, + [player_id, KA](SDL_Scancode sc) { Options::setKeyboardBinding(player_id, KA, sc); }); +} + auto ServiceMenu::buildKeyboardControls() -> Page { Page page; page.title_key = "menu.titles.keyboard"; - // Etiqueta divisòria per al jugador 1. + using KA = Options::KeyboardAction; + const auto AS_U8 = [](KA k) { return static_cast(k); }; + + // Bloc del jugador 1. Ordre: moviment → atacar → saltar. page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1"))); + page.items.push_back(makeKeyboardItem("menu.items.move_up", 0, AS_U8(KA::UP))); + page.items.push_back(makeKeyboardItem("menu.items.move_down", 0, AS_U8(KA::DOWN))); + page.items.push_back(makeKeyboardItem("menu.items.move_left", 0, AS_U8(KA::LEFT))); + page.items.push_back(makeKeyboardItem("menu.items.move_right", 0, AS_U8(KA::RIGHT))); + page.items.push_back(makeKeyboardItem("menu.items.attack", 0, AS_U8(KA::ATTACK))); + page.items.push_back(makeKeyboardItem("menu.items.jump", 0, AS_U8(KA::JUMP))); - // Vinculacions actuals: les llegim directament de `Defaults::KeysGame` - // (P1) i `Defaults::KeysGameP2` (P2). 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()` / `keysGameP2()`. - 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; })); - - // Bloc del jugador 2: mateixa forma, llegint de KeysGameP2. + // Bloc del jugador 2. page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_2"))); - page.items.push_back(makeKeyBind("menu.items.move_up", - []() { return Defaults::KeysGameP2::UP; })); - page.items.push_back(makeKeyBind("menu.items.move_down", - []() { return Defaults::KeysGameP2::DOWN; })); - page.items.push_back(makeKeyBind("menu.items.move_left", - []() { return Defaults::KeysGameP2::LEFT; })); - page.items.push_back(makeKeyBind("menu.items.move_right", - []() { return Defaults::KeysGameP2::RIGHT; })); + page.items.push_back(makeKeyboardItem("menu.items.move_up", 1, AS_U8(KA::UP))); + page.items.push_back(makeKeyboardItem("menu.items.move_down", 1, AS_U8(KA::DOWN))); + page.items.push_back(makeKeyboardItem("menu.items.move_left", 1, AS_U8(KA::LEFT))); + page.items.push_back(makeKeyboardItem("menu.items.move_right", 1, AS_U8(KA::RIGHT))); + page.items.push_back(makeKeyboardItem("menu.items.attack", 1, AS_U8(KA::ATTACK))); + page.items.push_back(makeKeyboardItem("menu.items.jump", 1, AS_U8(KA::JUMP))); return page; } @@ -874,6 +989,37 @@ namespace { } // namespace +namespace { + + // Acció: arranca DefineButtons per al mando assignat al jugador. + // Si no hi ha mando, no fa res — l'item ja s'amaga via `visible`. + void startDefineButtonsForPlayer(int player_id) { + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); + if (pad == nullptr) { return; } + const auto& slot = (player_id == 0) + ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + // Anem a buscar el nom/path actuals del mando físic, no els + // persistits — així podem captar correctament el mateix mando + // si l'usuari l'ha mogut de port USB i només en queda el nom. + std::string name = slot.name; + std::string path = slot.path; + const auto ALL = GamepadManager::get().getAll(); + const auto IT = std::ranges::find_if(ALL, + [pad](const auto& info) { return info.pad == pad; }); + if (IT != ALL.end()) { + name = IT->name; + path = IT->path; + } + ServiceMenu::get().startDefineButtons(pad, name, path); + } + + auto playerHasGamepad(int player_id) -> bool { + return GamepadManager::get().getForPlayer(player_id) != nullptr; + } + +} // namespace + auto ServiceMenu::buildGamepadControls() -> Page { Page page; page.title_key = "menu.titles.gamepad"; @@ -896,9 +1042,29 @@ auto ServiceMenu::buildGamepadControls() -> Page { [](std::size_t idx) { applyPadSelection(1, idx); }, [](std::size_t idx) { return padLabel(1, idx); })); + // Accions per a llançar `DefineButtons` per a cada slot assignat. + // Només visibles si el jugador té un mando vinculat — equivalent al + // `define_buttons_->enable(...)` que dispara l'acció a coffee_crisis. + Item define_p1 = makeAction("menu.items.define_buttons_p1", + []() { startDefineButtonsForPlayer(0); }); + define_p1.visible = []() { return playerHasGamepad(0); }; + page.items.push_back(std::move(define_p1)); + + Item define_p2 = makeAction("menu.items.define_buttons_p2", + []() { startDefineButtonsForPlayer(1); }); + define_p2.visible = []() { return playerHasGamepad(1); }; + page.items.push_back(std::move(define_p2)); + return page; } +void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name, const std::string& path) { + // Wrapper públic perquè els items del menú (factories estàtiques) + // puguin atacar el sub-mòdul sense exposar-lo. Evita haver de fer + // `define_buttons_` `public`. + define_buttons_.enable(pad, name, path); +} + auto ServiceMenu::buildSystem() -> Page { Page page; page.title_key = "menu.titles.system"; @@ -1038,10 +1204,13 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) first_item = false; const bool SELECTED = item.selectable && (i == page.cursor); + const bool CAPTURING_THIS = capturing_ && SELECTED && (item.kind == Item::Kind::KEY_BIND); const std::string LABEL_TEXT = (item.kind == Item::Kind::LABEL) ? item.label_key : Locale::get().text(item.label_key); - const std::string VALUE = formatValue(item); + const std::string VALUE = CAPTURING_THIS + ? Locale::get().text("menu.values.press_key") + : formatValue(item); Uint8 label_color = UiConfig::get().menuLabel(); if (item.kind == Item::Kind::LABEL) { @@ -1118,4 +1287,8 @@ void ServiceMenu::render() { }); renderPage(page, BOX_X, BOX_Y, BOX_WIDTH); Rendering::popClip(); + + // Overlay de captura de botons al damunt del menú. Es pinta sense + // clip perquè ocupa la seua pròpia caixa centrada. + define_buttons_.render(); } diff --git a/source/game/ui/service_menu.hpp b/source/game/ui/service_menu.hpp index 041490d..669f7f0 100644 --- a/source/game/ui/service_menu.hpp +++ b/source/game/ui/service_menu.hpp @@ -3,11 +3,13 @@ #include #include +#include #include #include #include #include +#include "core/input/define_buttons.hpp" #include "core/rendering/surface.hpp" // Palette class Text; @@ -56,6 +58,21 @@ class ServiceMenu { // false; és el caller qui decideix obrir-lo amb la hotkey. auto handleEvent(const SDL_Event& ev) -> bool; + // True mentre estem esperant que l'usuari pulse una tecla per assignar + // a l'item KEY_BIND seleccionat. El Director redirigeix els KEY_DOWN + // a `captureKey` mentre dura — replica `Menu::isCapturing` d'AEE. + [[nodiscard]] auto isCapturing() const -> bool { return capturing_; } + + // Aplica l'scancode capturat a l'item seleccionat (si encara és KEY_BIND + // i té setter). ESCAPE cancel·la sense assignar. + void captureKey(SDL_Scancode sc); + + // Arranca el sub-mòdul `DefineButtons` per al mando indicat. El menú + // queda "obert" però els events del mando es redirigeixen al sub-mòdul + // fins que acabi la seqüència o l'usuari pulse ESC. Replica el patró + // `define_buttons_->enable(...)` de coffee_crisis_arcade_edition. + void startDefineButtons(SDL_Gamepad* pad, const std::string& name, const std::string& path); + private: // --- Struct d'un item d'una pàgina --- // @@ -185,6 +202,11 @@ class ServiceMenu { // --- Navegació i estat --- void pushPage(Page page); void popPage(); + + // Tractament del bloc d'events de gamepad (BUTTON_DOWN). Extret a + // mètode propi per a mantenir la complexitat cognitiva d'`handleEvent` + // sota el llindar de clang-tidy. + void handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev); [[nodiscard]] auto top() -> Page&; [[nodiscard]] auto topConst() const -> const Page&; [[nodiscard]] static auto isItemVisible(const Item& item) -> bool; @@ -211,6 +233,14 @@ class ServiceMenu { [[nodiscard]] static auto makeShaderTypeItem() -> Item; [[nodiscard]] static auto makeShaderPresetItem() -> Item; + // Fàbrica genèrica per a un KEY_BIND vinculat a una acció del jugador + // `player_id`. Llegeix d'`Options::controls().keyboard_p*` i delega + // la mutació a `Options::setKeyboardBinding(...)` (persisteix). + [[nodiscard]] static auto makeKeyboardItem( + const std::string& label_key, + int player_id, + std::uint8_t action) -> Item; + // --- Render --- // "Visible" inclou l'animació de tancament: retorna true mentre la caixa // encara ha de dibuixar-se, encara que open_ siga false. @@ -232,4 +262,11 @@ class ServiceMenu { float animated_h_{0.0F}; // Alçada suavitzada cap a `targetBoxHeight`. bool transition_active_{false}; // Push/pop de pàgina en curs. float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina. + bool capturing_{false}; // True mentre esperem una tecla per a un KEY_BIND. + + // Sub-mòdul de captura de botons del mando. Quan està actiu absorbeix + // tots els events de mando (BUTTON_DOWN/UP, AXIS_MOTION) i, en acabar, + // persisteix la nova configuració via `GamepadBindings`. Replica el + // patró `define_buttons_` de coffee_crisis_arcade_edition. + DefineButtons define_buttons_; }; diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index af3cde4..4254547 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; - constexpr const char* VERSION = "0.29.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.30.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {