#include "game/ui/service_menu.hpp" #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" #include "core/rendering/canvas.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/text.hpp" #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "game/options.hpp" #include "game/options_apply.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" #include "version.h" namespace { // Geometria de la caixa (píxels de canvas lògic 320×240). // // Dos nivells de distància vertical: // - `GAP_Y` és la distància estructural: marge superior al títol, // títol↔separador, separador↔primer item i últim item↔marge inferior. // - `ITEM_GAP_Y` és la distància entre items consecutius (més tight // per a que la llista no quede massa airejada). constexpr int BOX_WIDTH = 260; constexpr int PAD_X = 10; constexpr int GAP_Y = 6; constexpr int ITEM_GAP_Y = 2; // Les fonts display reserven ~2 px de descendent al fons de la seua // caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests // glifs, eixos 2 px queden buits i el gap visual sota el títol sembla // més gran que el del damunt. Compensem-ho restant-los al càlcul // d'alçada i al posicionament del separador; així el títol queda // òpticament centrat entre la vora superior i la línia divisòria. constexpr int TITLE_DESCENDER_TRIM = 2; constexpr int MIN_ZOOM = 1; constexpr int MAX_ZOOM = 6; constexpr int VOLUME_STEP_PCT = 5; // Pas d'UI (5 %) per als volums. // --- Animació --- // // `open_anim_` va de 0 (invisible) a 1 (totalment obert). La velocitat // dicta quant triga a obrir-se o tancar-se. HEIGHT_RATE és el factor de // suavitzat exponencial per a l'alçada quan canvia el nombre d'items // visibles dins d'una mateixa pàgina. constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125 s constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1 s — més ràpid que obrir constexpr float HEIGHT_RATE = 12.0F; // smoothing alçada (~150 ms @ 90 %) constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms; reservat per al lliscament lateral de push/pop. constexpr float MS_PER_SECOND = 1000.0F; constexpr std::uint8_t BG_ALPHA = 220; // Opacitat màxima del fons. // Les claus de locale viuen al .yaml, però el `menu_toggle` id el llegim // ací a cada event per evitar cachejar un scancode que KeyConfig recàrrega. constexpr const char* KEY_ID_MENU_TOGGLE = "menu_toggle"; auto boolLabel(bool value) -> std::string { return Locale::get().text(value ? "menu.values.on" : "menu.values.off"); } auto scalingLabel(Defaults::ScalingMode mode) -> std::string { switch (mode) { case Defaults::ScalingMode::DISABLED: return Locale::get().text("menu.values.scaling_disabled"); case Defaults::ScalingMode::STRETCH: return Locale::get().text("menu.values.scaling_stretch"); case Defaults::ScalingMode::LETTERBOX: return Locale::get().text("menu.values.scaling_letterbox"); case Defaults::ScalingMode::OVERSCAN: return Locale::get().text("menu.values.scaling_overscan"); case Defaults::ScalingMode::INTEGER: return Locale::get().text("menu.values.scaling_integer"); } return {}; } auto renderInfoLabel(Defaults::RenderInfoPosition pos) -> std::string { switch (pos) { case Defaults::RenderInfoPosition::OFF: return Locale::get().text("menu.values.render_info_off"); case Defaults::RenderInfoPosition::TOP: return Locale::get().text("menu.values.render_info_top"); case Defaults::RenderInfoPosition::BOTTOM: return Locale::get().text("menu.values.render_info_bottom"); } return {}; } // Troba l'índex del preset actiu dins del vector; si no es troba cau a 0 // perquè el menú mostri el primer element disponible (evita índexs fora // de rang que petarien `presets[idx]`). template auto indexOfPreset(const std::vector& presets, const std::string& name) -> std::size_t { const auto IT = std::ranges::find_if(presets, [&](const Preset& p) { return p.name == name; }); return (IT == presets.end()) ? 0 : std::distance(presets.begin(), IT); } // Ease-out quadratic: 1 - (1 - t)². Accelera al principi i frena al final. // S'aplica a open_anim_ perquè el menú aparegui amb sensació de "pes". auto easeOutQuad(float t) -> float { t = std::clamp(t, 0.0F, 1.0F); const float INV = 1.0F - t; return 1.0F - (INV * INV); } } // namespace // --- Factories d'Items --- auto ServiceMenu::makeToggle( std::string label_key, std::function getter, std::function setter, std::function visible) -> Item { Item item; item.kind = Item::Kind::TOGGLE; item.label_key = std::move(label_key); item.get_bool = std::move(getter); item.set_bool = std::move(setter); item.visible = std::move(visible); return item; } auto ServiceMenu::makeCycle( std::string label_key, std::function get_index, std::function get_count, std::function set_index, std::function value_label, std::function visible) -> Item { Item item; item.kind = Item::Kind::CYCLE; item.label_key = std::move(label_key); item.get_index = std::move(get_index); item.get_count = std::move(get_count); item.set_index = std::move(set_index); item.value_label = std::move(value_label); item.visible = std::move(visible); return item; } auto ServiceMenu::makeIntRange( std::string label_key, std::function get_int, std::function set_int, int min_value, int max_value, int step, std::function int_display, std::function visible) -> Item { Item item; item.kind = Item::Kind::INT_RANGE; item.label_key = std::move(label_key); item.get_int = std::move(get_int); item.set_int = std::move(set_int); item.min_value = min_value; item.max_value = max_value; item.step = step; item.int_display = std::move(int_display); item.visible = std::move(visible); return item; } auto ServiceMenu::makeSubmenu(std::string label_key, std::function push_sub) -> Item { Item item; item.kind = Item::Kind::SUBMENU; item.label_key = std::move(label_key); item.push_sub = std::move(push_sub); return item; } auto ServiceMenu::makeAction(std::string label_key, std::function action) -> Item { Item item; item.kind = Item::Kind::ACTION; item.label_key = std::move(label_key); item.action = std::move(action); return item; } auto ServiceMenu::makeLabel(std::string text, std::function visible) -> Item { Item item; item.kind = Item::Kind::LABEL; item.label_key = std::move(text); // Per a LABEL el camp és text literal. item.visible = std::move(visible); item.selectable = false; return item; } auto ServiceMenu::makeKeyBind(std::string label_key, std::function getter, std::function setter) -> Item { Item item; item.kind = Item::Kind::KEY_BIND; item.label_key = std::move(label_key); item.get_scancode = std::move(getter); item.set_scancode = std::move(setter); // Si no hi ha setter, l'item queda purament informatiu: no pot capturar // una tecla nova. Així la primera iteració de la pàgina Controls pot // mostrar les vinculacions actuals sense tenir encara la infraestructura // de mutació a `Options::`. item.selectable = static_cast(item.set_scancode); return item; } // --- Cicle de vida --- auto ServiceMenu::get() -> ServiceMenu& { static ServiceMenu instance_; return instance_; } void ServiceMenu::init(std::shared_ptr title_font, std::shared_ptr items_font, const Palette& ui_palette) { font_ = std::move(title_font); items_font_ = std::move(items_font); ui_palette_ = ui_palette; } void ServiceMenu::toggle() { if (open_) { close(); } else { stack_.clear(); stack_.push_back(buildRoot()); open_ = true; closing_ = false; animated_h_ = 0.0F; // Comença des de 0 perquè la caixa crega. // Bloqueja l'input de polling: els consumidors com `Player` veuran // l'estat del teclat tot-a-zero mentre el menú sigui obert. Els // events (KEY_DOWN, etc.) ja els consumeix `processMenuInput`, // així que la navegació del menú no es veu afectada. InputState::setBlocked(true); } } void ServiceMenu::close() { if (!open_) { return; } open_ = false; closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura. // Allibera el polling de seguida — la closing animation és purament // visual i no ha d'impedir que el jugador es mogui un cop ha tancat. InputState::setBlocked(false); } // `isVisible` inclou l'animació de tancament: retorna true mentre open_anim_>0 // encara que open_ ja siga false. Així render() es crida fins que la caixa // ha acabat d'encongir-se. auto ServiceMenu::isVisible() const -> bool { return open_ || closing_ || open_anim_ > 0.0F; } void ServiceMenu::update(int delta_ms) { const float DT = static_cast(delta_ms) / MS_PER_SECOND; if (closing_) { open_anim_ -= CLOSE_SPEED * DT; if (open_anim_ <= 0.0F) { open_anim_ = 0.0F; closing_ = false; stack_.clear(); // Allibera les pàgines ara que la caixa ja no es veu. animated_h_ = 0.0F; transition_active_ = false; } } else if (open_ && open_anim_ < 1.0F) { open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * DT)); } // Transició push/pop entre pàgines. Mentre està activa, el contingut es // manté amagat i només es veu la caixa redimensionant-se fins a l'alçada // de la pàgina nova; quan acaba, reapareix el nou contingut. if (transition_active_) { transition_progress_ += TRANSITION_SPEED * DT; if (transition_progress_ >= 1.0F) { transition_progress_ = 1.0F; transition_active_ = false; } } // Alçada suavitzada: quan un item canvia la seua visibilitat en calent // (p.ex. shader off oculta shader_type i preset), la caixa reacciona amb // una animació en comptes de pegar un salt brusc. if (open_ && !stack_.empty()) { const auto TARGET = static_cast(targetBoxHeight(topConst())); if (animated_h_ <= 0.0F) { animated_h_ = TARGET; } else { const float DIFF = TARGET - animated_h_; if (std::fabs(DIFF) < 0.5F) { animated_h_ = TARGET; } else { const float T = std::min(1.0F, HEIGHT_RATE * DT); animated_h_ += DIFF * T; } } } } // --- Navegació de pila --- void ServiceMenu::pushPage(Page page) { // Cursor inicial: primer item visible I seleccionable; si la pàgina // només té LABELs, queda a 0 (cap item rebrà focus i l'usuari veu els // textos sense més). page.cursor = 0; for (std::size_t i = 0; i < page.items.size(); ++i) { const auto& item = page.items[i]; if ((!item.visible || item.visible()) && item.selectable) { page.cursor = i; break; } } stack_.push_back(std::move(page)); transition_active_ = true; transition_progress_ = 0.0F; } void ServiceMenu::popPage() { if (stack_.size() > 1) { stack_.pop_back(); transition_active_ = true; transition_progress_ = 0.0F; } else { close(); } } auto ServiceMenu::top() -> Page& { return stack_.back(); } auto ServiceMenu::topConst() const -> const Page& { return stack_.back(); } auto ServiceMenu::isItemVisible(const Item& item) -> bool { return !item.visible || item.visible(); } // Busca el pròxim item visible i seleccionable en la direcció indicada // (+1 avall, -1 amunt), amb wrap-around als extrems. Items no seleccionables // (p.ex. LABELs amb versió/hash) es salten perquè el cursor no hi cau. void ServiceMenu::moveCursor(int direction) { if (stack_.empty()) { return; } auto& page = top(); if (page.items.empty()) { return; } const auto N = page.items.size(); std::size_t idx = page.cursor; for (std::size_t step = 0; step < N; ++step) { idx = (idx + static_cast(direction + static_cast(N))) % N; if (isItemVisible(page.items[idx]) && page.items[idx].selectable) { page.cursor = idx; return; } } } void ServiceMenu::activateCycle(const Item& item, int direction) { if (!item.get_index || !item.get_count || !item.set_index) { return; } const auto COUNT = item.get_count(); if (COUNT == 0) { return; } const auto CUR = item.get_index(); // `direction` és +1 / -1. Traduïm sempre a un offset positiu dins del // rang del cicle per evitar barrejar int i size_t. const std::size_t OFFSET = (direction > 0) ? 1 : (COUNT - 1); item.set_index((CUR + OFFSET) % COUNT); } void ServiceMenu::activateIntRange(const Item& item, int direction) { if (!item.get_int || !item.set_int) { return; } const int CUR = item.get_int(); const int NEXT = std::clamp(CUR + (direction * item.step), item.min_value, item.max_value); if (NEXT != CUR) { item.set_int(NEXT); } } // Aplica l'acció de l'item seleccionat. `direction` val +1 per a // RIGHT/RETURN i -1 per a LEFT; els toggles usen +1 com a "invert". void ServiceMenu::activateCurrent(int direction) { if (stack_.empty()) { return; } const auto& page = topConst(); if (page.items.empty()) { return; } const auto& item = page.items[page.cursor]; if (!isItemVisible(item) || !item.selectable) { return; } switch (item.kind) { case Item::Kind::LABEL: break; // No interacciona. case Item::Kind::TOGGLE: if (item.get_bool && item.set_bool) { item.set_bool(!item.get_bool()); } break; case Item::Kind::CYCLE: activateCycle(item, direction); break; case Item::Kind::INT_RANGE: activateIntRange(item, direction); break; case Item::Kind::SUBMENU: if (direction > 0 && item.push_sub) { item.push_sub(); } break; case Item::Kind::ACTION: if (direction > 0 && item.action) { item.action(); } break; case Item::Kind::KEY_BIND: // TODO: entrar en mode captura quan hi hagi `set_scancode`. // De moment no fem res — els items sense setter ja no són // seleccionables (veure `makeKeyBind`), per tant aquest case // només es trepitja si algú en fa un amb setter en el futur. break; } } // --- Input --- auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool { if (!open_) { return false; } // 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; } return true; } if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } const auto SC = ev.key.scancode; if (SC == KeyConfig::get().scancode(KEY_ID_MENU_TOGGLE)) { close(); return true; } switch (SC) { case SDL_SCANCODE_UP: moveCursor(-1); break; case SDL_SCANCODE_DOWN: moveCursor(+1); break; case SDL_SCANCODE_LEFT: activateCurrent(-1); break; case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: activateCurrent(+1); break; case SDL_SCANCODE_BACKSPACE: case SDL_SCANCODE_ESCAPE: popPage(); break; default: break; } // Mentre el menú està obert consumim TOTS els KEY_DOWN perquè les // hotkeys globals (F1–F8, F11, F12) no s'activen per sota. return true; } // --- Builders de pàgines --- auto ServiceMenu::buildRoot() -> Page { Page page; page.title_key = "menu.titles.root"; page.items.push_back(makeSubmenu("menu.items.video_submenu", []() { ServiceMenu::get().pushPage(buildVideo()); })); page.items.push_back(makeSubmenu("menu.items.audio_submenu", []() { ServiceMenu::get().pushPage(buildAudio()); })); page.items.push_back(makeSubmenu("menu.items.controls_submenu", []() { ServiceMenu::get().pushPage(buildControls()); })); page.items.push_back(makeSubmenu("menu.items.system_submenu", []() { ServiceMenu::get().pushPage(buildSystem()); })); return page; } auto ServiceMenu::buildVideo() -> Page { using Mode = Defaults::ScalingMode; using Pos = Defaults::RenderInfoPosition; Page page; page.title_key = "menu.titles.video"; // Zoom: IntRange [1..6] amb display "x{N}". Amagat si fullscreen (com // a les hotkeys: no té efecte en pantalla completa). page.items.push_back(makeIntRange( "menu.items.zoom", []() { return Options::window().zoom; }, [](int v) { Options::setWindowZoom(v); Screen::get().setWindowZoom(v); }, MIN_ZOOM, MAX_ZOOM, 1, [](int v) { return "x" + std::to_string(v); }, []() { return !Options::window().fullscreen; })); page.items.push_back(makeToggle( "menu.items.screen", []() { return Options::window().fullscreen; }, [](bool v) { Options::setWindowFullscreen(v); Screen::get().setFullscreen(v); })); page.items.push_back(makeToggle( "menu.items.vsync", []() { return Options::video().vsync; }, [](bool v) { Options::setVideoVSync(v); Screen::get().setVSync(v); })); // Cicle dels 5 modes d'escalat. page.items.push_back(makeCycle( "menu.items.scaling_mode", []() { return static_cast(Options::video().scaling_mode); }, []() { return std::size_t{5}; }, [](std::size_t idx) { const auto NEW_MODE = static_cast(idx); Options::setVideoScalingMode(NEW_MODE); Screen::get().setScalingMode(NEW_MODE); }, [](std::size_t idx) { return scalingLabel(static_cast(idx)); })); page.items.push_back(makeToggle( "menu.items.shader", []() { return Options::video().shader.enabled; }, [](bool v) { Options::setShaderEnabled(v); OptionsApply::reapplyShader(); })); // Tipus de shader i preset: només visibles si el shader està actiu // (altrament no tenen efecte observable). Extrets a helpers per mantenir // la complexitat cognitiva de `buildVideo` sota el llindar de clang-tidy. page.items.push_back(makeShaderTypeItem()); page.items.push_back(makeShaderPresetItem()); page.items.push_back(makeCycle( "menu.items.render_info", []() { return static_cast(Options::video().render_info_position); }, []() { return static_cast(Defaults::RENDER_INFO_POSITION_COUNT); }, [](std::size_t idx) { Options::setVideoRenderInfoPosition(static_cast(idx)); }, [](std::size_t idx) { return renderInfoLabel(static_cast(idx)); })); page.items.push_back(makeToggle( "menu.items.uptime", []() { return Options::video().render_info_show_time; }, [](bool v) { Options::setVideoRenderInfoShowTime(v); }, []() { return Options::video().render_info_position != Pos::OFF; })); return page; } auto ServiceMenu::makeShaderTypeItem() -> Item { return makeCycle( "menu.items.shader_type", []() { return Options::video().shader.current_shader == "crtpi" ? std::size_t{1} : std::size_t{0}; }, []() { return std::size_t{2}; }, [](std::size_t idx) { Options::setActiveShaderName(idx == 1 ? "crtpi" : "postfx"); OptionsApply::reapplyShader(); }, [](std::size_t idx) { return idx == 1 ? std::string{"CRT-Pi"} : std::string{"PostFX"}; }, []() { return Options::video().shader.enabled; }); } auto ServiceMenu::makeShaderPresetItem() -> Item { // Els getters i setters es ramifiquen per `current_shader`: el selector // actiu viu a `postfxPresets()` o `crtpiPresets()` segons el tipus triat. auto is_crtpi = []() { return Options::video().shader.current_shader == "crtpi"; }; return makeCycle( "menu.items.preset", [is_crtpi]() { return is_crtpi() ? indexOfPreset(Options::crtpiPresets(), Options::video().shader.current_crtpi_preset) : indexOfPreset(Options::postfxPresets(), Options::video().shader.current_postfx_preset); }, [is_crtpi]() { return is_crtpi() ? Options::crtpiPresets().size() : Options::postfxPresets().size(); }, [is_crtpi](std::size_t idx) { if (is_crtpi()) { const auto& presets = Options::crtpiPresets(); if (idx < presets.size()) { Options::setCrtPiPreset(presets[idx].name); } } else { const auto& presets = Options::postfxPresets(); if (idx < presets.size()) { Options::setPostFXPreset(presets[idx].name); } } OptionsApply::reapplyShader(); }, [is_crtpi](std::size_t idx) -> std::string { if (is_crtpi()) { const auto& presets = Options::crtpiPresets(); return idx < presets.size() ? presets[idx].name : std::string{}; } const auto& presets = Options::postfxPresets(); return idx < presets.size() ? presets[idx].name : std::string{}; }, []() { return Options::video().shader.enabled; }); } auto ServiceMenu::buildAudio() -> Page { Page page; page.title_key = "menu.titles.audio"; auto volume_display = [](int pct) { return std::to_string(pct); }; page.items.push_back(makeToggle( "menu.items.master_enable", []() { return Options::audio().enabled; }, [](bool v) { Options::setAudioEnabled(v); OptionsApply::reapplyAudio(); })); page.items.push_back(makeIntRange( "menu.items.master_volume", []() { return Audio::toPercent(Options::audio().volume); }, [](int pct) { Options::setAudioVolume(Audio::fromPercent(pct)); OptionsApply::reapplyAudio(); }, 0, 100, VOLUME_STEP_PCT, volume_display, []() { return Options::audio().enabled; })); page.items.push_back(makeToggle( "menu.items.music", []() { return Options::audio().music.enabled; }, [](bool v) { Options::setAudioMusicEnabled(v); OptionsApply::reapplyAudio(); }, []() { return Options::audio().enabled; })); page.items.push_back(makeIntRange( "menu.items.music_volume", []() { return Audio::toPercent(Options::audio().music.volume); }, [](int pct) { Options::setAudioMusicVolume(Audio::fromPercent(pct)); OptionsApply::reapplyAudio(); }, 0, 100, VOLUME_STEP_PCT, volume_display, []() { return Options::audio().enabled && Options::audio().music.enabled; })); page.items.push_back(makeToggle( "menu.items.sounds", []() { return Options::audio().sound.enabled; }, [](bool v) { Options::setAudioSoundEnabled(v); OptionsApply::reapplyAudio(); }, []() { return Options::audio().enabled; })); page.items.push_back(makeIntRange( "menu.items.sounds_volume", []() { return Audio::toPercent(Options::audio().sound.volume); }, [](int pct) { Options::setAudioSoundVolume(Audio::fromPercent(pct)); OptionsApply::reapplyAudio(); }, 0, 100, VOLUME_STEP_PCT, volume_display, []() { return Options::audio().enabled && Options::audio().sound.enabled; })); return page; } auto ServiceMenu::buildControls() -> Page { Page page; page.title_key = "menu.titles.controls"; page.items.push_back(makeSubmenu("menu.items.keyboard_submenu", []() { ServiceMenu::get().pushPage(buildKeyboardControls()); })); // Comandament: la pàgina existeix però encara no té infraestructura // d'integració (no detectem gamepads ni mapegem botons). Entra-hi per // veure un placeholder fins que la lògica arribi. page.items.push_back(makeSubmenu("menu.items.gamepad_submenu", []() { ServiceMenu::get().pushPage(buildGamepadControls()); })); return page; } auto ServiceMenu::buildKeyboardControls() -> Page { Page page; page.title_key = "menu.titles.keyboard"; // Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2, // només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles // per sota, sense tocar cap altra cosa. page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1"))); // Vinculacions actuals: les llegim directament de `Defaults::KeysGame`. // Encara no hi ha setter, per tant els items surten com a informatius // (no seleccionables). La lògica de captura i persistència arribarà en // una segona iteració amb `Options::keysGameP1()`. page.items.push_back(makeKeyBind("menu.items.move_up", []() { return Defaults::KeysGame::UP; })); page.items.push_back(makeKeyBind("menu.items.move_down", []() { return Defaults::KeysGame::DOWN; })); page.items.push_back(makeKeyBind("menu.items.move_left", []() { return Defaults::KeysGame::LEFT; })); page.items.push_back(makeKeyBind("menu.items.move_right", []() { return Defaults::KeysGame::RIGHT; })); return page; } namespace { // Índex del cycle per a un jugador donat. 0 = "Cap" (desassignat o // sense coincidència amb cap mando connectat); 1..N = mando `idx-1` // de la llista actual de `GamepadManager::getAll()`. La coincidència // es fa primer per ruta (estable entre sessions) i si no, per nom. auto currentPadIndex(int player_id) -> std::size_t { const auto PADS = GamepadManager::get().getAll(); const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; if (slot.name.empty() && slot.path.empty()) { return 0; } for (std::size_t i = 0; i < PADS.size(); ++i) { if (!slot.path.empty() && PADS[i].path == slot.path) { return i + 1; } } for (std::size_t i = 0; i < PADS.size(); ++i) { if (!slot.name.empty() && PADS[i].name == slot.name) { return i + 1; } } return 0; } // Text visible per a cada índex del cycle. 0 → "Cap"; altrament // el nom del mando. Si l'índex queda fora de rang (p.ex. un mando // desenchufat just abans que es repinte el label) mostrem el nom // persistit amb un sufix "(desconnectat)" per no deixar l'usuari // sense feedback. auto padLabel(int player_id, std::size_t idx) -> std::string { if (idx == 0) { return Locale::get().text("menu.values.gamepad_none"); } const auto PADS = GamepadManager::get().getAll(); const std::size_t POS = idx - 1; if (POS < PADS.size()) { return PADS[POS].name; } const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')'; } // Aplica la selecció del cycle: 0 desassigna, altres índexs vinculen // el mando corresponent. Options persisteix i crida el GamepadManager. void applyPadSelection(int player_id, std::size_t idx) { if (idx == 0) { Options::setGamepadAssignment(player_id, "", ""); return; } const auto PADS = GamepadManager::get().getAll(); const std::size_t POS = idx - 1; if (POS >= PADS.size()) { return; } Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path); } } // namespace auto ServiceMenu::buildGamepadControls() -> Page { Page page; page.title_key = "menu.titles.gamepad"; // Un cycle per jugador. L'opció 0 és sempre "Cap"; la resta es // generen dinàmicament a partir de `GamepadManager::getAll()` cada // frame, així que un mando acabat de connectar ja apareix sense // haver de tornar a obrir el submenú. page.items.push_back(makeCycle( "menu.items.gamepad_p1", []() { return currentPadIndex(0); }, []() { return GamepadManager::get().getAll().size() + 1; }, [](std::size_t idx) { applyPadSelection(0, idx); }, [](std::size_t idx) { return padLabel(0, idx); })); page.items.push_back(makeCycle( "menu.items.gamepad_p2", []() { return currentPadIndex(1); }, []() { return GamepadManager::get().getAll().size() + 1; }, [](std::size_t idx) { applyPadSelection(1, idx); }, [](std::size_t idx) { return padLabel(1, idx); })); return page; } auto ServiceMenu::buildSystem() -> Page { Page page; page.title_key = "menu.titles.system"; // Tots els items són botons/etiquetes; no n'hi ha cap amb columna de // valor a la dreta, així que els centrem per tenir la jerarquia visual // pròpia d'un menú d'accions. page.center_items = true; // Versió + hash de commit com a primer item no-seleccionable. Format // "ver.X.Y.Z (hash)"; el hash el posa CMake a version.h.in. page.items.push_back(makeLabel( std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")")); // Reiniciar el joc: placeholder sense efecte fins que Director exposi // un pas de restart en calent (fora d'abast d'aquesta iteració). page.items.push_back(makeAction( "menu.items.restart", []() { // TODO: connectar amb un hipotètic Director::requestRestart(). })); page.items.push_back(makeAction( "menu.items.exit_game", []() { SDL_Event quit_event{}; quit_event.type = SDL_EVENT_QUIT; EventBuffer::events.push_back(quit_event); })); return page; } // --- Render --- auto ServiceMenu::formatValue(const Item& item) -> std::string { switch (item.kind) { case Item::Kind::LABEL: return {}; // Es dibuixa només la label (pàgina centrada o no). case Item::Kind::TOGGLE: return item.get_bool ? boolLabel(item.get_bool()) : std::string{}; case Item::Kind::CYCLE: return (item.value_label && item.get_index) ? item.value_label(item.get_index()) : std::string{}; case Item::Kind::INT_RANGE: { if (!item.get_int) { return {}; } const int V = item.get_int(); return item.int_display ? item.int_display(V) : std::to_string(V); } case Item::Kind::SUBMENU: return Locale::get().text("menu.values.submenu_arrow"); case Item::Kind::ACTION: return {}; case Item::Kind::KEY_BIND: return item.get_scancode ? SDL_GetScancodeName(item.get_scancode()) : std::string{}; } return {}; } auto ServiceMenu::targetBoxHeight(const Page& page) const -> int { if (!font_ || !items_font_) { return 0; } const int TITLE_H = font_->getLineHeight(); const int ITEM_H = items_font_->getLineHeight(); const auto VISIBLE_ITEMS = static_cast(std::ranges::count_if(page.items, [](const Item& item) { return isItemVisible(item); })); // Layout: GAP_Y enmig dels blocs estructurals (top, títol↔separador, // separador↔primer item, últim item↔bottom). Entre items consecutius, // ITEM_GAP_Y (més petit, perquè els items no queden massa airejats). int h = GAP_Y; // marge superior h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // títol + gap (compensant descendent) if (!page.subtitle.empty()) { h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // subtítol idem } h += 1 + GAP_Y; // separador d'1 px + gap estructural if (VISIBLE_ITEMS > 0) { h += VISIBLE_ITEMS * ITEM_H; h += (VISIBLE_ITEMS - 1) * ITEM_GAP_Y; h += GAP_Y; // marge inferior } return h; } void ServiceMenu::renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const { const std::uint32_t BG_ARGB = ui_palette_[UiConfig::get().menuBg()]; const std::uint32_t BORDER_ARGB = ui_palette_[UiConfig::get().menuBorder()]; const auto ALPHA = static_cast(std::lround(BG_ALPHA * eased)); // Fons semi-transparent (el joc queda visible apagat per darrere). Screen::get().blendRect(box_x, box_y, box_w, box_h, BG_ARGB, ALPHA); // Vora opaca per damunt perquè la caixa quedi definida. Screen::get().fillRect(box_x, box_y, box_w, 1, BORDER_ARGB); Screen::get().fillRect(box_x, box_y + box_h - 1, box_w, 1, BORDER_ARGB); Screen::get().fillRect(box_x, box_y, 1, box_h, BORDER_ARGB); Screen::get().fillRect(box_x + box_w - 1, box_y, 1, box_h, BORDER_ARGB); } void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) const { const int TITLE_KERNING = font_->getDefaultKerning(); const int TITLE_H = font_->getLineHeight(); const int ITEM_KERNING = items_font_->getDefaultKerning(); const int ITEM_H = items_font_->getLineHeight(); int cursor_y = box_y + GAP_Y; // marge superior const int CENTER_X = box_x + (box_w / 2); // Amplada reservada per al cursor "> ". La label no canvia de posició // quan canvia la selecció — el cursor es pinta dins d'aquest carril, // a l'esquerra, i la label sempre comença al mateix X. const int CURSOR_WIDTH = items_font_->length("> ", ITEM_KERNING); const int LABEL_X = box_x + PAD_X + CURSOR_WIDTH; font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, CENTER_X, cursor_y, Locale::get().text(page.title_key), TITLE_KERNING, UiConfig::get().menuTitle()); cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; if (!page.subtitle.empty()) { font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, CENTER_X, cursor_y, page.subtitle, TITLE_KERNING, UiConfig::get().menuTitle()); cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; } Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiConfig::get().menuBorder()]); cursor_y += 1 + GAP_Y; bool first_item = true; for (std::size_t i = 0; i < page.items.size(); ++i) { const auto& item = page.items[i]; if (!isItemVisible(item)) { continue; } if (!first_item) { cursor_y += ITEM_GAP_Y; } first_item = false; const bool SELECTED = item.selectable && (i == page.cursor); 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); Uint8 label_color = UiConfig::get().menuLabel(); if (item.kind == Item::Kind::LABEL) { // Mateix to que el títol de la pàgina: les LABELs actuen com // a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a // Sistema) i han de tenir la mateixa presència visual. label_color = UiConfig::get().menuTitle(); } else if (SELECTED) { label_color = UiConfig::get().menuCursor(); } int label_x = LABEL_X; if (page.center_items) { // En pàgines centrades, la label cau al centre del box i el // cursor es col·loca immediatament a la seua esquerra. const int LABEL_W = items_font_->length(LABEL_TEXT, ITEM_KERNING); label_x = CENTER_X - (LABEL_W / 2); } if (SELECTED) { items_font_->writeDX(Text::COLOR_FLAG, label_x - CURSOR_WIDTH, cursor_y, "> ", ITEM_KERNING, UiConfig::get().menuCursor()); } items_font_->writeDX(Text::COLOR_FLAG, label_x, cursor_y, LABEL_TEXT, ITEM_KERNING, label_color); // Pàgines centrades no dibuixen columna de valor (s'assumeix que // només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines // d'opcions amb layout esquerra-dreta. if (!page.center_items && !VALUE.empty()) { const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING); items_font_->writeDX(Text::COLOR_FLAG, box_x + box_w - PAD_X - VALUE_W, cursor_y, VALUE, ITEM_KERNING, SELECTED ? UiConfig::get().menuCursor() : UiConfig::get().menuValue()); } cursor_y += ITEM_H; } } void ServiceMenu::render() { if (!isVisible() || stack_.empty() || !font_) { return; } const auto& page = topConst(); const float EASED = easeOutQuad(open_anim_); // Alçada animada per suavitzar transicions entre pàgines i canvis de // visibilitat d'items; l'eased la multiplica per simular el "créixer" // vertical durant l'obertura. const int FULL_H = static_cast(animated_h_ > 0.0F ? animated_h_ : targetBoxHeight(page)); int box_h = std::max(2, static_cast(static_cast(FULL_H) * EASED)); const int BOX_X = (GameCanvas::WIDTH - BOX_WIDTH) / 2; const int BOX_Y = (GameCanvas::HEIGHT - box_h) / 2; renderBox(BOX_X, BOX_Y, BOX_WIDTH, box_h, EASED); // Clip al rectangle interior de la caixa (dins de la vora, que és d'1 // px). Així el text que cau fora — durant el creixement inicial, el // canvi d'alçada en amagar/mostrar items, o el push/pop entre pàgines // — queda retallat en comptes d'anar a parar sobre l'escena. Rendering::pushClip({ .x = BOX_X + 1, .y = BOX_Y + 1, .w = BOX_WIDTH - 2, .h = std::max(0, box_h - 2), }); renderPage(page, BOX_X, BOX_Y, BOX_WIDTH); Rendering::popClip(); }