diff --git a/source/core/rendering/menu.cpp b/source/core/rendering/menu.cpp index 662e905..e028fa5 100644 --- a/source/core/rendering/menu.cpp +++ b/source/core/rendering/menu.cpp @@ -1,5 +1,6 @@ #include "core/rendering/menu.hpp" +#include #include #include #include @@ -38,7 +39,8 @@ namespace Menu { static constexpr int HEADER_H = TITLE_PAD_Y + 8 /*charH*/ + 2 + 4; // títol + línia + gap // --- Animació --- - static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s + static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s + static constexpr float HEIGHT_RATE = 12.0F; // smoothing exponencial de l'alçada (~150 ms al 90%) // --- Items --- enum class ItemKind { Toggle, @@ -54,6 +56,7 @@ namespace Menu { std::function change; // per Toggle/Cycle/IntRange std::function enter; // per Submenu SDL_Scancode* scancode{nullptr}; // per KeyBind + std::function visible; // nullptr ⇒ sempre visible }; struct Page { @@ -62,10 +65,25 @@ namespace Menu { int cursor{0}; }; + static bool isVisible(const Item& it) { return !it.visible || it.visible(); } + + // Troba el pròxim ítem visible en direcció `dir` (±1) a partir de `from`. + // Si cap és visible retorna `from`. + static int nextVisibleCursor(const Page& p, int from, int dir) { + const int n = static_cast(p.items.size()); + if (n <= 0) return from; + for (int i = 1; i <= n; ++i) { + int idx = ((from + dir * i) % n + n) % n; + if (isVisible(p.items[idx])) return idx; + } + return from; + } + // --- Estat --- static std::vector stack_; static std::unique_ptr font_; static float open_anim_{0.0F}; // 0 = tancat, 1 = obert + static float animated_h_{0.0F}; // alçada actual animada (smoothing cap al target visible) static Uint32 last_ticks_{0}; static SDL_Scancode* capturing_{nullptr}; // != null → esperant tecla per assignar @@ -116,44 +134,60 @@ namespace Menu { static Page buildVideo() { Page p{Locale::get("menu.titles.video"), {}, 0}; + // Zoom i fullscreen: sense sentit a WASM (el navegador posseix el canvas) +#ifndef __EMSCRIPTEN__ p.items.push_back({Locale::get("menu.items.zoom"), ItemKind::IntRange, [] { char buf[16]; std::snprintf(buf, sizeof(buf), "%dX", Screen::get()->getZoom()); return std::string(buf); }, [](int dir) { if (dir < 0) Screen::get()->decZoom(); - else if (dir > 0) Screen::get()->incZoom(); }, nullptr}); + else if (dir > 0) Screen::get()->incZoom(); }, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.screen"), ItemKind::Toggle, [] { return std::string(Screen::get()->isFullscreen() ? Locale::get("menu.values.fullscreen") : Locale::get("menu.values.windowed")); }, [](int) { Screen::get()->toggleFullscreen(); }, nullptr}); + p.items.push_back({Locale::get("menu.items.screen"), ItemKind::Toggle, [] { return std::string(Screen::get()->isFullscreen() ? Locale::get("menu.values.fullscreen") : Locale::get("menu.values.windowed")); }, [](int) { Screen::get()->toggleFullscreen(); }, nullptr, nullptr, nullptr}); +#endif - p.items.push_back({Locale::get("menu.items.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, nullptr}); + // Opcions visuals generals (sempre visibles) + p.items.push_back({Locale::get("menu.items.aspect_4_3"), ItemKind::Toggle, [] { return yesNo(Options::video.aspect_ratio_4_3); }, [](int) { Screen::get()->toggleAspectRatio(); }, nullptr, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.aspect_4_3"), ItemKind::Toggle, [] { return yesNo(Options::video.aspect_ratio_4_3); }, [](int) { Screen::get()->toggleAspectRatio(); }, nullptr}); + p.items.push_back({Locale::get("menu.items.vsync"), ItemKind::Toggle, [] { return onOff(Options::video.vsync); }, [](int) { Screen::get()->toggleVSync(); }, nullptr, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.supersampling"), ItemKind::Toggle, [] { return onOff(Options::video.supersampling); }, [](int) { Screen::get()->toggleSupersampling(); }, nullptr}); + p.items.push_back({Locale::get("menu.items.integer_scale"), ItemKind::Toggle, [] { return onOff(Options::video.integer_scale); }, [](int) { Screen::get()->toggleIntegerScale(); }, nullptr, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.vsync"), ItemKind::Toggle, [] { return onOff(Options::video.vsync); }, [](int) { Screen::get()->toggleVSync(); }, nullptr}); + p.items.push_back({Locale::get("menu.items.stretch_filter"), ItemKind::Toggle, [] { return std::string(Options::video.stretch_filter_linear ? Locale::get("menu.values.linear") : Locale::get("menu.values.nearest")); }, [](int) { Screen::get()->toggleStretchFilter(); }, nullptr, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.integer_scale"), ItemKind::Toggle, [] { return onOff(Options::video.integer_scale); }, [](int) { Screen::get()->toggleIntegerScale(); }, nullptr}); + // Bloc shaders: no disponible a WASM (NO_SHADERS, sense SDL3 GPU a WebGL2) +#ifndef __EMSCRIPTEN__ + p.items.push_back({Locale::get("menu.items.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, nullptr, nullptr, nullptr}); p.items.push_back({Locale::get("menu.items.shader_type"), ItemKind::Cycle, [] { return std::string(Screen::get()->getActiveShaderName()); }, [](int dir) { if (dir < 0) Screen::get()->prevShaderType(); - else Screen::get()->nextShaderType(); }, nullptr}); + else Screen::get()->nextShaderType(); }, nullptr, nullptr, + [] { return Options::video.shader_enabled; }}); p.items.push_back({Locale::get("menu.items.preset"), ItemKind::Cycle, [] { return std::string(Screen::get()->getCurrentPresetName()); }, [](int dir) { if (dir < 0) Screen::get()->prevPreset(); - else Screen::get()->nextPreset(); }, nullptr}); + else Screen::get()->nextPreset(); }, nullptr, nullptr, + [] { return Options::video.shader_enabled; }}); - p.items.push_back({Locale::get("menu.items.stretch_filter"), ItemKind::Toggle, [] { return std::string(Options::video.stretch_filter_linear ? Locale::get("menu.values.linear") : Locale::get("menu.values.nearest")); }, [](int) { Screen::get()->toggleStretchFilter(); }, nullptr}); + p.items.push_back({Locale::get("menu.items.supersampling"), ItemKind::Toggle, [] { return onOff(Options::video.supersampling); }, [](int) { Screen::get()->toggleSupersampling(); }, nullptr, nullptr, + [] { + if (!Options::video.shader_enabled) return false; + const char* name = Screen::get()->getActiveShaderName(); + return name && std::string(name) == "POSTFX"; + }}); +#endif + // Informació de render p.items.push_back({Locale::get("menu.items.render_info"), ItemKind::Cycle, [] { switch (Options::render_info.position) { case Options::RenderInfoPosition::OFF: return std::string(Locale::get("menu.values.off")); case Options::RenderInfoPosition::TOP: return std::string(Locale::get("menu.values.top")); case Options::RenderInfoPosition::BOTTOM: return std::string(Locale::get("menu.values.bottom")); } - return std::string(Locale::get("menu.values.off")); }, [](int dir) { Overlay::cycleRenderInfo(dir); }, nullptr}); + return std::string(Locale::get("menu.values.off")); }, [](int dir) { Overlay::cycleRenderInfo(dir); }, nullptr, nullptr, nullptr}); - p.items.push_back({Locale::get("menu.items.uptime"), ItemKind::Toggle, [] { return onOff(Options::render_info.show_time); }, [](int) { Options::render_info.show_time = !Options::render_info.show_time; }, nullptr}); + p.items.push_back({Locale::get("menu.items.uptime"), ItemKind::Toggle, [] { return onOff(Options::render_info.show_time); }, [](int) { Options::render_info.show_time = !Options::render_info.show_time; }, nullptr, nullptr, + [] { return Options::render_info.position != Options::RenderInfoPosition::OFF; }}); return p; } @@ -263,9 +297,12 @@ namespace Menu { fillRect(buf, x + w - 1, y, 1, h, color); } - // Mida final de la caixa segons el nombre d'items + // Mida final de la caixa segons el nombre d'items *visibles* static int boxHeight(const Page& page) { - int n = static_cast(page.items.size()); + int n = 0; + for (const auto& it : page.items) { + if (isVisible(it)) ++n; + } int body = (n == 0) ? ITEM_SPACING : n * ITEM_SPACING; return HEADER_H + body + BOTTOM_PAD; } @@ -294,6 +331,7 @@ namespace Menu { } else { stack_.push_back(buildRoot()); open_anim_ = 0.0F; + animated_h_ = static_cast(boxHeight(stack_.back())); last_ticks_ = SDL_GetTicks(); } } @@ -301,6 +339,7 @@ namespace Menu { void close() { stack_.clear(); open_anim_ = 0.0F; + animated_h_ = 0.0F; capturing_ = nullptr; transition_active_ = false; transition_progress_ = 1.0F; @@ -334,13 +373,17 @@ namespace Menu { } return; } - const int n = static_cast(page.items.size()); + // Si el cursor està sobre un ítem ocultat (p. ex. una acció anterior el va ocultar), + // reubica'l al pròxim visible abans de processar l'entrada. + if (!isVisible(page.items[page.cursor])) { + page.cursor = nextVisibleCursor(page, page.cursor, +1); + } switch (sc) { case SDL_SCANCODE_UP: - page.cursor = (page.cursor - 1 + n) % n; + page.cursor = nextVisibleCursor(page, page.cursor, -1); break; case SDL_SCANCODE_DOWN: - page.cursor = (page.cursor + 1) % n; + page.cursor = nextVisibleCursor(page, page.cursor, +1); break; case SDL_SCANCODE_LEFT: if (page.items[page.cursor].kind != ItemKind::Submenu && @@ -373,6 +416,15 @@ namespace Menu { default: break; } + // Després de qualsevol acció, si el cursor quedara sobre un ítem ocult + // (possible si una acció ha canviat la visibilitat pròpia de l'ítem actual, + // edge case defensiu), salta al següent visible. + if (!stack_.empty()) { + Page& top = stack_.back(); + if (!top.items.empty() && !isVisible(top.items[top.cursor])) { + top.cursor = nextVisibleCursor(top, top.cursor, +1); + } + } } // Dibuixa el contingut d'una pàgina amb un offset horitzontal i un rang de clip. @@ -398,17 +450,23 @@ namespace Menu { // Items o placeholder buit int items_y = title_line_y + 4; - if (page.items.empty()) { + // Compta visibles — si cap, dibuixa placeholder (caixa totalment col·lapsada però oberta) + int visible_count = 0; + for (const auto& it : page.items) if (isVisible(it)) ++visible_count; + if (visible_count == 0) { const char* empty_text = Locale::get("menu.values.empty"); int ew = font_->width(empty_text); font_->drawClipped(pixel_data, box_x + (BOX_W - ew) / 2 + x_offset, items_y + 2, empty_text, EMPTY_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); return; } + int y_slot = 0; // índex de fila visible (independent de l'índex real de l'ítem) for (size_t i = 0; i < page.items.size(); i++) { - int y = items_y + static_cast(i) * ITEM_SPACING; - bool selected = (static_cast(i) == page.cursor); const Item& item = page.items[i]; + if (!isVisible(item)) continue; + int y = items_y + y_slot * ITEM_SPACING; + ++y_slot; + bool selected = (static_cast(i) == page.cursor); if (selected) { font_->drawClipped(pixel_data, box_x + 4 + x_offset, y, ">", CURSOR_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); @@ -462,14 +520,30 @@ namespace Menu { const Page& page = stack_.back(); const int current_h = boxHeight(page); + // Smoothing exponencial de l'alçada cap al target (pàgina actual + ítems visibles). + // Permet que el menú reaccione amb animació quan una opció canvia la visibilitat + // d'altres ítems en calent (p. ex. shader=off → shader_type/preset/supersampling). + if (animated_h_ <= 0.0F) { + animated_h_ = static_cast(current_h); + } else { + float diff = static_cast(current_h) - animated_h_; + if (std::fabs(diff) < 0.5F) { + animated_h_ = static_cast(current_h); + } else { + float t = HEIGHT_RATE * dt; + if (t > 1.0F) t = 1.0F; + animated_h_ += diff * t; + } + } + float eased = Easing::outQuad(open_anim_); // Calcula alçada (amb transició si escau) - int target_h = current_h; + int target_h = static_cast(animated_h_); if (transition_active_) { int outgoing_h = boxHeight(transition_outgoing_); float tp = Easing::outQuad(transition_progress_); - target_h = Easing::lerpInt(outgoing_h, current_h, tp); + target_h = Easing::lerpInt(outgoing_h, static_cast(animated_h_), tp); } // Caixa creix verticalment durant l'obertura