#include "core/rendering/menu.hpp" #include #include #include #include #include #include #include #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" #include "core/rendering/overlay.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/text.hpp" #include "core/system/director.hpp" #include "game/defines.hpp" #include "game/options.hpp" #include "utils/easing.hpp" #include "version.h" namespace Menu { // --- Constants visuals --- static constexpr int SCREEN_W = 320; static constexpr int SCREEN_H = 200; static constexpr int BOX_W = 220; static constexpr Uint32 BG_COLOR = 0xFF1A0E0E; // fons marró fosc (ABGR) static constexpr Uint8 BG_ALPHA = 220; // semi-transparent static constexpr Uint32 BORDER_COLOR = 0xFFFFFF00; // cyan static constexpr Uint32 TITLE_COLOR = 0xFFFFFFFF; // blanc static constexpr Uint32 LABEL_COLOR = 0xFFCCCCCC; // gris clar static constexpr Uint32 VALUE_COLOR = 0xFFFFFF00; // cyan static constexpr Uint32 CURSOR_COLOR = 0xFF00FFFF; // groc static constexpr Uint32 EMPTY_COLOR = 0xFF888888; // gris static constexpr int TITLE_PAD_Y = 4; static constexpr int ITEM_PAD_X = 10; static constexpr int ITEM_SPACING = 11; static constexpr int BOTTOM_PAD = 6; static constexpr int HEADER_H = TITLE_PAD_Y + 8 /*charH*/ + 2 + 4; // títol + línia + gap static constexpr int SUBTITLE_H = 8 + 3; // línia de subtítol + gap // --- Animació --- static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s static constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1s (una mica més ràpida que l'obertura) static constexpr float HEIGHT_RATE = 12.0F; // smoothing exponencial de l'alçada (~150 ms al 90%) // --- Items --- enum class ItemKind : std::uint8_t { TOGGLE, CYCLE, INT_RANGE, SUBMENU, KEY_BIND, ACTION }; struct Item { const char* label; ItemKind kind; std::function get_value; // opcional std::function change; // per TOGGLE/CYCLE/INT_RANGE std::function enter; // per SUBMENU i ACTION SDL_Scancode* scancode{nullptr}; // per KEY_BIND std::function visible; // nullptr ⇒ sempre visible }; struct Page { const char* title; std::vector items; int cursor{0}; std::string subtitle; // opcional — si no buit, es dibuixa sota el títol }; static auto isVisible(const Item& it) -> bool { 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 auto nextVisibleCursor(const Page& p, int from, int dir) -> int { 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 static bool closing{false}; // true mentre l'animació de tancament és en curs // --- Transició entre pàgines --- static constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms static Page transition_outgoing{.title = "", .items = {}, .cursor = 0}; static bool transition_active{false}; static float transition_progress{1.0F}; static int transition_dir{+1}; // +1 endavant, -1 enrere // Helpers per triggerar transicions static void pushPage(Page new_page) { transition_outgoing = stack.back(); stack.push_back(std::move(new_page)); transition_active = true; transition_progress = 0.0F; transition_dir = +1; } static void popPage() { transition_outgoing = stack.back(); stack.pop_back(); transition_active = true; transition_progress = 0.0F; transition_dir = -1; } // --- Helpers --- static auto yesNo(bool b) -> std::string { return b ? Locale::get("menu.values.yes") : Locale::get("menu.values.no"); } static auto onOff(bool b) -> std::string { return b ? Locale::get("menu.values.on") : Locale::get("menu.values.off"); } // --- Builders de pàgines --- static auto buildVideo() -> Page; static auto buildAudio() -> Page; static auto buildControls() -> Page; static auto buildGame() -> Page; static auto buildSystem() -> Page; static auto buildRoot() -> Page { Page p{.title = Locale::get("menu.titles.root"), .items = {}, .cursor = 0}; p.items.push_back({Locale::get("menu.items.video"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildVideo()); }, nullptr}); p.items.push_back({Locale::get("menu.items.audio"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildAudio()); }, nullptr}); p.items.push_back({Locale::get("menu.items.controls"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildControls()); }, nullptr}); p.items.push_back({Locale::get("menu.items.game"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildGame()); }, nullptr}); p.items.push_back({Locale::get("menu.items.system"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildSystem()); }, nullptr}); return p; } static auto buildVideo() -> Page { Page p{.title = Locale::get("menu.titles.video"), .items = {}, .cursor = 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::INT_RANGE, [] { 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, 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 // 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.vsync"), ItemKind::TOGGLE, [] { return onOff(Options::video.vsync); }, [](int) { Screen::get()->toggleVSync(); }, nullptr, nullptr, nullptr}); p.items.push_back({Locale::get("menu.items.scaling_mode"), ItemKind::CYCLE, [] { switch (Options::video.scaling_mode) { case Options::ScalingMode::DISABLED: return std::string(Locale::get("menu.values.scaling_disabled")); case Options::ScalingMode::STRETCH: return std::string(Locale::get("menu.values.scaling_stretch")); case Options::ScalingMode::LETTERBOX: return std::string(Locale::get("menu.values.scaling_letterbox")); case Options::ScalingMode::OVERSCAN: return std::string(Locale::get("menu.values.scaling_overscan")); case Options::ScalingMode::INTEGER: return std::string(Locale::get("menu.values.scaling_integer")); } return std::string(Locale::get("menu.values.scaling_integer")); }, [](int dir) { Screen::get()->cycleScalingMode(dir); }, nullptr, nullptr, nullptr}); p.items.push_back({Locale::get("menu.items.texture_filter"), ItemKind::CYCLE, [] { return std::string(Options::video.texture_filter == Options::TextureFilter::LINEAR ? Locale::get("menu.values.linear") : Locale::get("menu.values.nearest")); }, [](int dir) { Screen::get()->cycleTextureFilter(dir); }, nullptr, nullptr, nullptr}); p.items.push_back({Locale::get("menu.items.internal_resolution"), ItemKind::INT_RANGE, [] { char buf[16]; std::snprintf(buf, sizeof(buf), "%dX", Options::video.internal_resolution); return std::string(buf); }, [](int dir) { Screen::get()->changeInternalResolution(dir); }, nullptr, nullptr, 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, 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, nullptr, [] { return Options::video.shader_enabled; }}); #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, 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, nullptr, [] { return Options::render_info.position != Options::RenderInfoPosition::OFF; }}); return p; } // Converteix volum 0..1 a percentatge i ho formata com "50%" static auto volPct(float v) -> std::string { int pct = static_cast(std::lround(v * 100.0F)); pct = std::max(pct, 0); pct = std::min(pct, 100); char buf[8]; std::snprintf(buf, sizeof(buf), "%d%%", pct); return {buf}; } // Canvi +/- d'un volum en steps de 0.05 (5%) amb clamping static void stepVolume(float& v, int dir) { v += (dir >= 0 ? 0.05F : -0.05F); v = std::max(v, 0.0F); v = std::min(v, 1.0F); Options::applyAudio(); } static auto buildControls() -> Page { Page p{.title = Locale::get("menu.titles.controls"), .items = {}, .cursor = 0}; p.items.push_back({Locale::get("menu.items.move_up"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.up}); p.items.push_back({Locale::get("menu.items.move_down"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.down}); p.items.push_back({Locale::get("menu.items.move_left"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.left}); p.items.push_back({Locale::get("menu.items.move_right"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.right}); p.items.push_back({Locale::get("menu.items.menu_key"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, KeyConfig::scancodePtr("menu_toggle")}); return p; } static auto buildAudio() -> Page { Page p{.title = Locale::get("menu.titles.audio"), .items = {}, .cursor = 0}; p.items.push_back({Locale::get("menu.items.master_enable"), ItemKind::TOGGLE, [] { return onOff(Options::audio.enabled); }, [](int) { Options::audio.enabled = !Options::audio.enabled; Options::applyAudio(); }, nullptr}); p.items.push_back({Locale::get("menu.items.master_volume"), ItemKind::INT_RANGE, [] { return volPct(Options::audio.volume); }, [](int dir) { stepVolume(Options::audio.volume, dir); }, nullptr}); p.items.push_back({Locale::get("menu.items.music"), ItemKind::TOGGLE, [] { return onOff(Options::audio.music.enabled); }, [](int) { Options::audio.music.enabled = !Options::audio.music.enabled; Options::applyAudio(); }, nullptr}); p.items.push_back({Locale::get("menu.items.music_volume"), ItemKind::INT_RANGE, [] { return volPct(Options::audio.music.volume); }, [](int dir) { stepVolume(Options::audio.music.volume, dir); }, nullptr}); p.items.push_back({Locale::get("menu.items.sounds"), ItemKind::TOGGLE, [] { return onOff(Options::audio.sound.enabled); }, [](int) { Options::audio.sound.enabled = !Options::audio.sound.enabled; Options::applyAudio(); }, nullptr}); p.items.push_back({Locale::get("menu.items.sounds_volume"), ItemKind::INT_RANGE, [] { return volPct(Options::audio.sound.volume); }, [](int dir) { stepVolume(Options::audio.sound.volume, dir); }, nullptr}); return p; } static auto buildGame() -> Page { Page p{.title = Locale::get("menu.titles.game"), .items = {}, .cursor = 0}; p.items.push_back({Locale::get("menu.items.use_new_logo"), ItemKind::TOGGLE, [] { return yesNo(Options::game.use_new_logo); }, [](int) { Options::game.use_new_logo = !Options::game.use_new_logo; }, nullptr}); p.items.push_back({Locale::get("menu.items.show_title_credits"), ItemKind::TOGGLE, [] { return yesNo(Options::game.show_title_credits); }, [](int) { Options::game.show_title_credits = !Options::game.show_title_credits; }, nullptr}); p.items.push_back({Locale::get("menu.items.show_preload"), ItemKind::TOGGLE, [] { return yesNo(Options::game.show_preload); }, [](int) { Options::game.show_preload = !Options::game.show_preload; }, nullptr}); return p; } static auto buildSystem() -> Page { Page p{.title = Locale::get("menu.titles.system"), .items = {}, .cursor = 0}; p.subtitle = std::string("v") + Texts::VERSION + " (" + Version::GIT_HASH + ")"; p.items.push_back({Locale::get("menu.items.restart"), ItemKind::ACTION, nullptr, nullptr, [] { if (Director::get()) { Director::get()->requestRestart(); } }, nullptr, nullptr}); #ifndef __EMSCRIPTEN__ p.items.push_back({Locale::get("menu.items.exit_game"), ItemKind::ACTION, nullptr, nullptr, [] { if (Director::get()) { Director::get()->requestQuit(); } }, nullptr, nullptr}); #endif return p; } // --- Dibuix --- // Alpha blending per pixel sobre el buffer ARGB (ABGR en memòria) static void blendRect(Uint32* buf, int x, int y, int w, int h, Uint32 src_argb, Uint8 src_alpha) { const Uint8 SA = src_alpha; const Uint8 SR = src_argb & 0xFF; const Uint8 SG = (src_argb >> 8) & 0xFF; const Uint8 SB = (src_argb >> 16) & 0xFF; const Uint8 INV = 255 - SA; for (int row = y; row < y + h; row++) { if (row < 0 || row >= SCREEN_H) { continue; } for (int col = x; col < x + w; col++) { if (col < 0 || col >= SCREEN_W) { continue; } Uint32* p = &buf[col + (row * SCREEN_W)]; Uint32 dst = *p; Uint8 dr = dst & 0xFF; Uint8 dg = (dst >> 8) & 0xFF; Uint8 db = (dst >> 16) & 0xFF; Uint8 r = (SR * SA + dr * INV) / 255; Uint8 g = (SG * SA + dg * INV) / 255; Uint8 b = (SB * SA + db * INV) / 255; *p = 0xFF000000U | (static_cast(b) << 16) | (static_cast(g) << 8) | r; } } } static void fillRect(Uint32* buf, int x, int y, int w, int h, Uint32 color) { for (int row = y; row < y + h; row++) { if (row < 0 || row >= SCREEN_H) { continue; } for (int col = x; col < x + w; col++) { if (col < 0 || col >= SCREEN_W) { continue; } buf[col + (row * SCREEN_W)] = color; } } } static void drawBorder(Uint32* buf, int x, int y, int w, int h, Uint32 color) { fillRect(buf, x, y, w, 1, color); fillRect(buf, x, y + h - 1, w, 1, color); fillRect(buf, x, y, 1, h, color); fillRect(buf, x + w - 1, y, 1, h, color); } // Mida final de la caixa segons el nombre d'items *visibles*. // body = (N-1) * ITEM_SPACING + charH — així BOTTOM_PAD és el buit real // sota el text del darrer ítem, no un buit extra per sobre d'un "slot" buit. static auto boxHeight(const Page& page) -> int { const int N = static_cast(std::count_if(page.items.begin(), page.items.end(), [](const auto& it) { return isVisible(it); })); int body = (N == 0) ? 8 : ((N - 1) * ITEM_SPACING) + 8; int header = HEADER_H + (page.subtitle.empty() ? 0 : SUBTITLE_H); return header + body + BOTTOM_PAD; } // --- API pública --- void init() { font = std::make_unique("fonts/8bithud.fnt", "fonts/8bithud.gif"); stack.clear(); open_anim = 0.0F; closing = false; last_ticks = SDL_GetTicks(); } void destroy() { font.reset(); stack.clear(); closing = false; } // "Actiu": accepta input. Durant l'animació de tancament la pila encara // té pàgines però ja no ha de processar tecles. auto isOpen() -> bool { return !stack.empty() && !closing; } // "Visible": encara hi ha caixa per pintar (incloent close animation). auto isVisible() -> bool { return !stack.empty(); } void toggle() { if (closing && !stack.empty()) { // Cancel·la el tancament en curs — continua l'animació cap a "obert" // des del valor actual d'open_anim. closing = false; last_ticks = SDL_GetTicks(); return; } if (isOpen()) { close(); } else { stack.push_back(buildRoot()); open_anim = 0.0F; closing = false; animated_h = static_cast(boxHeight(stack.back())); last_ticks = SDL_GetTicks(); } } // close() no buida la pila immediatament: marca closing i deixa que // render() faça decréixer open_anim fins a 0. En aquell moment es neteja // l'estat. Si es crida estant ja tancat o tancant-se, no-op. void close() { if (stack.empty() || closing) { return; } closing = true; capturing = nullptr; transition_active = false; transition_progress = 1.0F; last_ticks = SDL_GetTicks(); } auto isCapturing() -> bool { return capturing != nullptr; } void captureKey(SDL_Scancode sc) { if (capturing == nullptr) { return; } if (sc == SDL_SCANCODE_ESCAPE) { // Cancel·la capturing = nullptr; return; } *capturing = sc; capturing = nullptr; } static void backOrClose() { if (stack.size() > 1) { popPage(); } else { close(); } } // Activació d'un ítem (RETURN/KP_ENTER): SUBMENU/ACTION criden enter, // KEY_BIND inicia captura, la resta avança change(+1). static void activateItem(Item& item) { if (item.kind == ItemKind::SUBMENU || item.kind == ItemKind::ACTION) { if (item.enter) { item.enter(); } } else if (item.kind == ItemKind::KEY_BIND) { capturing = item.scancode; } else if (item.change) { item.change(+1); } } static void applyKeyToItem(Page& page, SDL_Scancode sc) { Item& item = page.items[page.cursor]; switch (sc) { case SDL_SCANCODE_UP: page.cursor = nextVisibleCursor(page, page.cursor, -1); break; case SDL_SCANCODE_DOWN: page.cursor = nextVisibleCursor(page, page.cursor, +1); break; case SDL_SCANCODE_LEFT: if (item.kind != ItemKind::SUBMENU && item.change) { item.change(-1); } break; case SDL_SCANCODE_RIGHT: if (item.kind != ItemKind::SUBMENU && item.change) { item.change(+1); } break; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: activateItem(item); break; case SDL_SCANCODE_BACKSPACE: backOrClose(); break; default: break; } } void handleKey(SDL_Scancode sc) { if (!isOpen()) { return; } Page& page = stack.back(); if (page.items.empty()) { if (sc == SDL_SCANCODE_BACKSPACE) { backOrClose(); } return; } if (!isVisible(page.items[page.cursor])) { page.cursor = nextVisibleCursor(page, page.cursor, +1); } applyKeyToItem(page, sc); // Defensa: si una acció ha amagat l'ítem que tenim sota el cursor, // saltem al pròxim visible. if (!stack.empty()) { Page& top = stack.back(); if (!top.items.empty() && !isVisible(top.items[top.cursor])) { top.cursor = nextVisibleCursor(top, top.cursor, +1); } } } // Forward decl: renderOneItem viu sota renderPageContent però aquest l'ha de cridar. static void renderOneItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max); // Dibuixa el contingut d'una pàgina amb un offset horitzontal i un rang de clip. // box_x/box_y són les coordenades de la caixa (per calcular posicions relatives); // clip_x_min/clip_x_max limiten on es dibuixa text i la línia separadora. static void renderPageContent(Uint32* pixel_data, const Page& page, int box_x, int box_y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) { // Títol int title_w = font->width(page.title); int title_x = box_x + ((BOX_W - title_w) / 2) + x_offset; font->drawClipped(pixel_data, title_x, box_y + TITLE_PAD_Y, page.title, TITLE_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); // Línia sota el títol (també lliscada) — clippada manualment int title_line_y = box_y + TITLE_PAD_Y + font->charHeight() + 2; if (title_line_y >= clip_y_min && title_line_y < clip_y_max) { int line_x = box_x + 4 + x_offset; int line_w = BOX_W - 8; int line_start = line_x < clip_x_min ? clip_x_min : line_x; int line_end = (line_x + line_w) > clip_x_max ? clip_x_max : (line_x + line_w); if (line_end > line_start) { fillRect(pixel_data, line_start, title_line_y, line_end - line_start, 1, BORDER_COLOR); } } // Subtítol opcional (sota la línia del títol, abans dels items) int items_y = title_line_y + 4; if (!page.subtitle.empty()) { int sub_w = font->width(page.subtitle.c_str()); int sub_x = box_x + ((BOX_W - sub_w) / 2) + x_offset; font->drawClipped(pixel_data, sub_x, items_y, page.subtitle.c_str(), LABEL_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); items_y += SUBTITLE_H; } // Compta visibles — si cap, dibuixa placeholder (caixa totalment col·lapsada però oberta) const int VISIBLE_COUNT = static_cast(std::count_if(page.items.begin(), page.items.end(), [](const auto& it) { return isVisible(it); })); 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; for (size_t i = 0; i < page.items.size(); i++) { const Item& item = page.items[i]; if (!isVisible(item)) { continue; } const int Y = items_y + (y_slot * ITEM_SPACING); ++y_slot; const bool SELECTED = (static_cast(i) == page.cursor); renderOneItem(pixel_data, item, SELECTED, box_x, Y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } } static auto keybindText(const Item& item, bool this_capturing) -> const char* { const char* text = nullptr; if (this_capturing) { text = Locale::get("menu.values.press_key"); } else if (item.scancode != nullptr) { text = SDL_GetScancodeName(*item.scancode); } else { text = ""; } if ((text == nullptr) || (*text == 0)) { text = Locale::get("menu.values.unknown"); } return text; } static void renderActionItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) { const Uint32 LABEL_COLOR_LOCAL = selected ? CURSOR_COLOR : LABEL_COLOR; const int LW = font->width(item.label); const int LX = box_x + ((BOX_W - LW) / 2) + x_offset; if (selected) { font->drawClipped(pixel_data, LX - font->width("> "), y, ">", CURSOR_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } font->drawClipped(pixel_data, LX, y, item.label, LABEL_COLOR_LOCAL, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } static auto keybindColor(bool this_capturing, bool selected) -> Uint32 { if (this_capturing) { return 0xFF00FFFF; } return selected ? CURSOR_COLOR : VALUE_COLOR; } static void renderItemValue(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) { if (item.kind == ItemKind::SUBMENU) { const char* arrow = ">>"; const int AW = font->width(arrow); const Uint32 AC = selected ? CURSOR_COLOR : VALUE_COLOR; font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - AW + x_offset, y, arrow, AC, clip_x_min, clip_x_max, clip_y_min, clip_y_max); return; } if (item.kind == ItemKind::KEY_BIND) { const bool THIS_CAPTURING = (capturing == item.scancode); const char* text = keybindText(item, THIS_CAPTURING); const int TW = font->width(text); const Uint32 TC = keybindColor(THIS_CAPTURING, selected); font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - TW + x_offset, y, text, TC, clip_x_min, clip_x_max, clip_y_min, clip_y_max); return; } if (item.get_value) { const std::string VALUE = item.get_value(); const int VALUE_W = font->width(VALUE.c_str()); const Uint32 VALUE_COLOR_LOCAL = selected ? CURSOR_COLOR : VALUE_COLOR; font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - VALUE_W + x_offset, y, VALUE.c_str(), VALUE_COLOR_LOCAL, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } } static void renderOneItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) { if (item.kind == ItemKind::ACTION) { renderActionItem(pixel_data, item, selected, box_x, y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max); return; } const Uint32 LABEL_COLOR_LOCAL = selected ? CURSOR_COLOR : LABEL_COLOR; 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); } font->drawClipped(pixel_data, box_x + ITEM_PAD_X + x_offset, y, item.label, LABEL_COLOR_LOCAL, clip_x_min, clip_x_max, clip_y_min, clip_y_max); renderItemValue(pixel_data, item, selected, box_x, y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } void render(Uint32* pixel_data) { if (!isVisible() || !font || (pixel_data == nullptr)) { return; } // Delta time Uint32 now = SDL_GetTicks(); float dt = static_cast(now - last_ticks) / 1000.0F; last_ticks = now; if (closing) { open_anim -= CLOSE_SPEED * dt; if (open_anim <= 0.0F) { // Animació de tancament completada — buida l'estat de veritat. open_anim = 0.0F; stack.clear(); animated_h = 0.0F; closing = false; return; } } else if (open_anim < 1.0F) { open_anim += OPEN_SPEED * dt; open_anim = std::min(open_anim, 1.0F); } // Avança transició if (transition_active) { transition_progress += TRANSITION_SPEED * dt; if (transition_progress >= 1.0F) { transition_progress = 1.0F; transition_active = false; } } 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; t = std::min(t, 1.0F); animated_h += diff * t; } } float eased = Easing::outQuad(open_anim); // Calcula alçada (amb transició si escau) 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, static_cast(animated_h), tp); } // Caixa creix verticalment durant l'obertura int box_h = static_cast(target_h * eased); box_h = std::max(box_h, 2); int box_x = (SCREEN_W - BOX_W) / 2; int box_y = (SCREEN_H - box_h) / 2; // Fons semi-transparent (alpha escalat per l'animació d'obertura) auto alpha = static_cast(BG_ALPHA * eased); blendRect(pixel_data, box_x, box_y, BOX_W, box_h, BG_COLOR, alpha); // El contingut només apareix quan la caixa és prou gran if (open_anim >= 0.9F) { int clip_x_min = box_x + 1; int clip_x_max = box_x + BOX_W - 1; int clip_y_min = box_y + 1; int clip_y_max = box_y + box_h - 1; if (transition_active) { float tp = Easing::outQuad(transition_progress); int out_offset = static_cast(-transition_dir * BOX_W * tp); int new_offset = static_cast(transition_dir * BOX_W * (1.0F - tp)); renderPageContent(pixel_data, transition_outgoing, box_x, box_y, out_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max); renderPageContent(pixel_data, page, box_x, box_y, new_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } else { renderPageContent(pixel_data, page, box_x, box_y, 0, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } } // Vora per damunt de tot drawBorder(pixel_data, box_x, box_y, BOX_W, box_h, BORDER_COLOR); } } // namespace Menu