#include "game/ui/service_menu.hpp" #include #include #include #include #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" #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; // Mínim buit entre la label (a l'esquerra) i el value (a la dreta). // Si amb el value sencer no queda aquest gap, truncam amb "..." des // del final del value. constexpr int MIN_LABEL_VALUE_GAP = 6; // 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; // 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() { 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. 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); } // `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) { // 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_) { 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: // 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ó configurat per a una acció concreta (SERVICE / ATTACK // / JUMP) al mando que ha emès aquest event. -1 si no troba cap match // (mando desconegut o desconnectat mentrestant). auto actionButtonForEvent(SDL_JoystickID jid, GamepadBindings::Action action) -> 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, action); } return -1; } } // namespace void ServiceMenu::playSelectSound() { Audio::get()->playSound(UiConfig::get().menuSelectSound(), Audio::Group::INTERFACE); } void ServiceMenu::playAcceptSound() { Audio::get()->playSound(UiConfig::get().menuAcceptSound(), Audio::Group::INTERFACE); } void ServiceMenu::handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev) { // Llindars amb histèresi: cal travessar STICK_THRESHOLD per "entrar" a // un extrem, però només es torna a la zona neutra quan baixa de // STICK_RELEASE. Així evitem que petits tremolors al voltant del // llindar facin spam d'events. Valors triats per donar la mateixa // sensació que el stick del jugador (deadzone=12000) però una mica // més estrictes per a la navegació discreta del menú. constexpr Sint16 STICK_THRESHOLD = 16000; constexpr Sint16 STICK_RELEASE = 8000; std::int8_t* zone = nullptr; bool horizontal = false; switch (ev.axis) { case SDL_GAMEPAD_AXIS_LEFTX: zone = &lx_zone_; horizontal = true; break; case SDL_GAMEPAD_AXIS_LEFTY: zone = &ly_zone_; horizontal = false; break; case SDL_GAMEPAD_AXIS_RIGHTX: zone = &rx_zone_; horizontal = true; break; case SDL_GAMEPAD_AXIS_RIGHTY: zone = &ry_zone_; horizontal = false; break; default: return; // Ignorem L2/R2 i la resta. } // Càlcul de la zona nova: si el valor està més enllà del llindar fort, // entra a l'extrem; si està dins de la zona morta, torna a 0; altrament // manté la zona prèvia (banda d'histèresi). std::int8_t new_zone = *zone; if (ev.value > STICK_THRESHOLD) { new_zone = +1; } else if (ev.value < -STICK_THRESHOLD) { new_zone = -1; } else if (std::abs(ev.value) < STICK_RELEASE) { new_zone = 0; } if (new_zone == *zone) { return; } *zone = new_zone; if (new_zone == 0) { return; } // Soltar el stick no dispara res. if (horizontal) { activateCurrent(new_zone); playAcceptSound(); } else { moveCursor(new_zone); playSelectSound(); } } void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) { // Tots tres botons rellevants (SERVICE/ATTACK/JUMP) són remapejables per // dispositiu via `GamepadBindings`. Resolem-los una sola vegada a partir // del joystick instance ID i comparem; els defaults (WEST=ATTACK, // SOUTH=JUMP, BACK=SERVICE) preserven el comportament històric. const int SERVICE_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::SERVICE); const int ACCEPT_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::ATTACK); const int CANCEL_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::JUMP); const int BTN = static_cast(ev.button); if (SERVICE_BTN >= 0 && BTN == SERVICE_BTN) { close(); playSelectSound(); return; } if (ACCEPT_BTN >= 0 && BTN == ACCEPT_BTN) { activateCurrent(+1); playAcceptSound(); return; } if (CANCEL_BTN >= 0 && BTN == CANCEL_BTN) { popPage(); playSelectSound(); return; } switch (ev.button) { case SDL_GAMEPAD_BUTTON_DPAD_UP: moveCursor(-1); playSelectSound(); break; case SDL_GAMEPAD_BUTTON_DPAD_DOWN: moveCursor(+1); playSelectSound(); break; case SDL_GAMEPAD_BUTTON_DPAD_LEFT: activateCurrent(-1); playAcceptSound(); break; case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: activateCurrent(+1); playAcceptSound(); 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) { handleGamepadButtonDown(ev.gbutton); return true; } if (ev.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { handleGamepadAxisMotion(ev.gaxis); 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); playSelectSound(); break; case SDL_SCANCODE_DOWN: moveCursor(+1); playSelectSound(); break; case SDL_SCANCODE_LEFT: activateCurrent(-1); playAcceptSound(); break; case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: activateCurrent(+1); playAcceptSound(); break; case SDL_SCANCODE_BACKSPACE: case SDL_SCANCODE_ESCAPE: popPage(); playSelectSound(); 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; } 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"; 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))); // Bloc del jugador 2. page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_2"))); 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; } namespace { // Trunca `text` amb "..." perquè la seua amplada renderitzada no // supere `max_w` píxels amb la font i el kerning donats. Port del // helper de coffee_crisis_arcade_edition: estimació inicial a partir // de l'amplada mitjana per caràcter i ajust iteratiu mesurant amb // `Text::length`, per a fonts d'amplada variable on l'estimació // només és aproximada. auto truncateToFit(const Text& font, const std::string& text, int max_w, int kerning) -> std::string { if (font.length(text, kerning) <= max_w) { return text; } static constexpr const char* ELLIPSIS = "..."; const int ELLIPSIS_W = font.length(ELLIPSIS, kerning); if (max_w <= ELLIPSIS_W || text.empty()) { return ELLIPSIS; } const auto TOTAL_W = static_cast(font.length(text, kerning)); const auto AVG_CHAR_W = TOTAL_W / static_cast(text.size()); auto keep = static_cast(static_cast(max_w - ELLIPSIS_W) / AVG_CHAR_W); keep = std::min(keep, text.size()); std::string out = text.substr(0, keep) + ELLIPSIS; while (keep > 0 && font.length(out, kerning) > max_w) { --keep; out = text.substr(0, keep) + ELLIPSIS; } return out; } // Dibuixa el value d'un item a la dreta del box, truncant-lo amb // "..." si no cap a l'esquerra de la label. Extret de `renderPage` // per a mantenir la complexitat cognitiva sota el llindar de // clang-tidy. void drawItemValue(Text& font, int box_x, int box_w, int cursor_y, int cursor_width, const std::string& label, const std::string& value, int kerning, Uint8 color) { const int LABEL_W = font.length(label, kerning); const int AVAILABLE_W = box_w - (2 * PAD_X) - cursor_width - LABEL_W - MIN_LABEL_VALUE_GAP; if (AVAILABLE_W <= 0) { return; } const std::string DISPLAY = truncateToFit(font, value, AVAILABLE_W, kerning); if (DISPLAY.empty()) { return; } const int VALUE_W = font.length(DISPLAY, kerning); font.writeDX(Text::COLOR_FLAG, box_x + box_w - PAD_X - VALUE_W, cursor_y, DISPLAY, kerning, color); } // Mandos visibles al cycle d'un jugador: tots els connectats // EXCEPTE els reclamats per l'altre jugador. Així l'usuari mai pot // assignar el mateix mando a dos jugadors des de la UI — per // reassignar-lo ha d'alliberar-lo primer posant-lo a "Cap" a l'altre // slot. L'ordre de la llista manté el de `getAll()`. auto visiblePads(int player_id) -> std::vector { const auto ALL = GamepadManager::get().getAll(); const auto& other = (player_id == 0) ? Options::controls().gamepad_p2 : Options::controls().gamepad_p1; std::vector out; out.reserve(ALL.size()); for (const auto& p : ALL) { const bool OWNED_BY_OTHER = (!other.path.empty() && p.path == other.path) || (other.path.empty() && !other.name.empty() && p.name == other.name); if (!OWNED_BY_OTHER) { out.push_back(p); } } return out; } // Índex del cycle dins de la llista `visiblePads(player_id)`. // 0 = "Cap" (desassignat o sense coincidència amb cap mando visible); // 1..N = mando `idx-1`. Primer match per ruta, fallback per nom. auto currentPadIndex(int player_id) -> std::size_t { const auto PADS = visiblePads(player_id); 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; } 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 = visiblePads(player_id); const std::size_t POS = idx - 1; if (POS < PADS.size()) { return PADS[POS].name; } // Fora de rang: l'assignació guardada no és visible ara (p. ex. // el mando s'acaba de desconnectar). Mostrem el nom persistit // amb un sufix per no deixar l'usuari sense feedback. const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')'; } void applyPadSelection(int player_id, std::size_t idx) { if (idx == 0) { Options::setGamepadAssignment(player_id, "", ""); return; } const auto PADS = visiblePads(player_id); const std::size_t POS = idx - 1; if (POS >= PADS.size()) { return; } Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path); } } // 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"; // 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 visiblePads(0).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 visiblePads(1).size() + 1; }, [](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"; // 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: tornem al LOGO amb `reset=true` perquè LogoScene // ature música i sons abans de mostrar-se. Tanquem el menú a la // mateixa acció — Director recollirà la transició al final del frame // via `takePendingSceneRequest()` i reconstruirà l'escena. page.items.push_back(makeAction( "menu.items.restart", []() { ServiceMenu::get().requestSceneTransition(ExitRequest{ .next = SceneId::LOGO, .params = LogoParams{.reset = true}, }); ServiceMenu::get().close(); })); 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 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 = CAPTURING_THIS ? Locale::get().text("menu.values.press_key") : 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 Uint8 VALUE_COLOR = SELECTED ? UiConfig::get().menuCursor() : UiConfig::get().menuValue(); drawItemValue(*items_font_, box_x, box_w, cursor_y, CURSOR_WIDTH, LABEL_TEXT, VALUE, ITEM_KERNING, VALUE_COLOR); } 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(); // Overlay de captura de botons al damunt del menú. Es pinta sense // clip perquè ocupa la seua pròpia caixa centrada. define_buttons_.render(); }