#include "core/rendering/menu.hpp" #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 "game/options.hpp" #include "utils/easing.hpp" 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 // --- Animació --- static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s // --- Items --- enum class ItemKind { Toggle, Cycle, IntRange, Submenu, KeyBind }; struct Item { const char* label; ItemKind kind; std::function getValue; // opcional std::function change; // per Toggle/Cycle/IntRange std::function enter; // per Submenu SDL_Scancode* scancode{nullptr}; // per KeyBind }; struct Page { const char* title; std::vector items; int cursor{0}; }; // --- Estat --- static std::vector stack_; static std::unique_ptr font_; static float open_anim_{0.0F}; // 0 = tancat, 1 = obert static Uint32 last_ticks_{0}; static SDL_Scancode* capturing_{nullptr}; // != null → esperant tecla per assignar // --- Transició entre pàgines --- static constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms static Page transition_outgoing_{"", {}, 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 newPage) { transition_outgoing_ = stack_.back(); stack_.push_back(std::move(newPage)); 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 std::string yesNo(bool b) { return b ? Locale::get("menu.values.yes") : Locale::get("menu.values.no"); } static std::string onOff(bool b) { return b ? Locale::get("menu.values.on") : Locale::get("menu.values.off"); } // --- Builders de pàgines --- static Page buildVideo(); static Page buildAudio(); static Page buildControls(); static Page buildGame(); static Page buildRoot() { Page p{Locale::get("menu.titles.root"), {}, 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}); return p; } static Page buildVideo() { Page p{Locale::get("menu.titles.video"), {}, 0}; 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}); 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.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, 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.supersampling"), ItemKind::Toggle, [] { return onOff(Options::video.supersampling); }, [](int) { Screen::get()->toggleSupersampling(); }, 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.integer_scale"), ItemKind::Toggle, [] { return onOff(Options::video.integer_scale); }, [](int) { Screen::get()->toggleIntegerScale(); }, 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}); 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}); 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.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}); 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}); return p; } // Converteix volum 0..1 a percentatge i ho formata com "50%" static std::string volPct(float v) { int pct = static_cast(v * 100.0F + 0.5F); if (pct < 0) pct = 0; if (pct > 100) pct = 100; char buf[8]; std::snprintf(buf, sizeof(buf), "%d%%", pct); return std::string(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); if (v < 0.0F) v = 0.0F; if (v > 1.0F) v = 1.0F; Options::applyAudio(); } static Page buildControls() { Page p{Locale::get("menu.titles.controls"), {}, 0}; p.items.push_back({Locale::get("menu.items.move_up"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.up}); p.items.push_back({Locale::get("menu.items.move_down"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.down}); p.items.push_back({Locale::get("menu.items.move_left"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.left}); p.items.push_back({Locale::get("menu.items.move_right"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.right}); p.items.push_back({Locale::get("menu.items.menu_key"), ItemKind::KeyBind, nullptr, nullptr, nullptr, KeyConfig::scancodePtr("menu_toggle")}); return p; } static Page buildAudio() { Page p{Locale::get("menu.titles.audio"), {}, 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::IntRange, [] { 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::IntRange, [] { 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::IntRange, [] { return volPct(Options::audio.sound_volume); }, [](int dir) { stepVolume(Options::audio.sound_volume, dir); }, nullptr}); return p; } static Page buildGame() { Page p{Locale::get("menu.titles.game"), {}, 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}); 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 static int boxHeight(const Page& page) { int n = static_cast(page.items.size()); int body = (n == 0) ? ITEM_SPACING : n * ITEM_SPACING; return HEADER_H + body + BOTTOM_PAD; } // --- API pública --- void init() { font_ = std::make_unique("fonts/8bithud.fnt", "fonts/8bithud.gif"); stack_.clear(); open_anim_ = 0.0F; last_ticks_ = SDL_GetTicks(); } void destroy() { font_.reset(); stack_.clear(); } auto isOpen() -> bool { return !stack_.empty(); } void toggle() { if (isOpen()) { close(); } else { stack_.push_back(buildRoot()); open_anim_ = 0.0F; last_ticks_ = SDL_GetTicks(); } } void close() { stack_.clear(); open_anim_ = 0.0F; capturing_ = nullptr; transition_active_ = false; transition_progress_ = 1.0F; } auto isCapturing() -> bool { return capturing_ != nullptr; } void captureKey(SDL_Scancode sc) { if (!capturing_) return; if (sc == SDL_SCANCODE_ESCAPE) { // Cancel·la capturing_ = nullptr; return; } *capturing_ = sc; capturing_ = nullptr; } void handleKey(SDL_Scancode sc) { if (!isOpen()) return; Page& page = stack_.back(); if (page.items.empty()) { // Pàgina buida — només backspace surt if (sc == SDL_SCANCODE_BACKSPACE) { if (stack_.size() > 1) popPage(); else close(); } return; } const int n = static_cast(page.items.size()); switch (sc) { case SDL_SCANCODE_UP: page.cursor = (page.cursor - 1 + n) % n; break; case SDL_SCANCODE_DOWN: page.cursor = (page.cursor + 1) % n; break; case SDL_SCANCODE_LEFT: if (page.items[page.cursor].kind != ItemKind::Submenu && page.items[page.cursor].change) { page.items[page.cursor].change(-1); } break; case SDL_SCANCODE_RIGHT: if (page.items[page.cursor].kind != ItemKind::Submenu && page.items[page.cursor].change) { page.items[page.cursor].change(+1); } break; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: if (page.items[page.cursor].kind == ItemKind::Submenu) { if (page.items[page.cursor].enter) page.items[page.cursor].enter(); } else if (page.items[page.cursor].kind == ItemKind::KeyBind) { capturing_ = page.items[page.cursor].scancode; } else if (page.items[page.cursor].change) { page.items[page.cursor].change(+1); } break; case SDL_SCANCODE_BACKSPACE: if (stack_.size() > 1) popPage(); else close(); break; default: break; } } // 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); } } // Items o placeholder buit int items_y = title_line_y + 4; if (page.items.empty()) { 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; } 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 (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); } Uint32 label_color = selected ? CURSOR_COLOR : LABEL_COLOR; font_->drawClipped(pixel_data, box_x + ITEM_PAD_X + x_offset, y, item.label, label_color, clip_x_min, clip_x_max, clip_y_min, clip_y_max); if (item.kind == ItemKind::Submenu) { const char* arrow = ">>"; int aw = font_->width(arrow); 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); } else if (item.kind == ItemKind::KeyBind) { bool this_capturing = (capturing_ == item.scancode); const char* text = this_capturing ? Locale::get("menu.values.press_key") : (item.scancode ? SDL_GetScancodeName(*item.scancode) : ""); if (!text || !*text) text = Locale::get("menu.values.unknown"); int tw = font_->width(text); Uint32 tc = this_capturing ? 0xFF00FFFF : (selected ? CURSOR_COLOR : VALUE_COLOR); 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); } else if (item.getValue) { std::string value = item.getValue(); int value_w = font_->width(value.c_str()); Uint32 value_color = 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, clip_x_min, clip_x_max, clip_y_min, clip_y_max); } } } void render(Uint32* pixel_data) { if (!isOpen() || !font_ || !pixel_data) return; // Delta time Uint32 now = SDL_GetTicks(); float dt = static_cast(now - last_ticks_) / 1000.0F; last_ticks_ = now; if (open_anim_ < 1.0F) { open_anim_ += OPEN_SPEED * dt; if (open_anim_ > 1.0F) 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); float eased = Easing::outQuad(open_anim_); // Calcula alçada (amb transició si escau) int target_h = current_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); } // Caixa creix verticalment durant l'obertura int box_h = static_cast(target_h * eased); if (box_h < 2) 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) Uint8 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