// service_menu.cpp - Implementacio del menu de servei // © 2026 JailDesigner #include "core/system/service_menu.hpp" #include #include #include #include #include "core/audio/audio.hpp" #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" #include "core/types.hpp" namespace { // Easing ease-out quadratic per a l'obertura/tancament. Identic a // aee_arcade service_menu.cpp:114-120. auto easeOutQuad(float t) -> float { t = std::clamp(t, 0.0F, 1.0F); const float INV = 1.0F - t; return 1.0F - (INV * INV); } // Canvas logic del joc (constants compartides amb la resta del renderer). constexpr float CANVAS_W = 1280.0F; constexpr float CANVAS_H = 720.0F; // Crida pushRect amb un SDL_Color (els components s'escalen a [0..1]). void fillRect(Rendering::Renderer* renderer, float x, float y, float w, float h, SDL_Color color) { renderer->pushRect(x, y, w, h, static_cast(color.r) / 255.0F, static_cast(color.g) / 255.0F, static_cast(color.b) / 255.0F, static_cast(color.a) / 255.0F); } void playSelectSound() { if (auto* audio = Audio::get(); audio != nullptr) { audio->playSound(Defaults::ServiceMenu::SELECT_SOUND, Audio::Group::INTERFACE); } } void playAcceptSound() { if (auto* audio = Audio::get(); audio != nullptr) { audio->playSound(Defaults::ServiceMenu::ACCEPT_SOUND, Audio::Group::INTERFACE); } } } // namespace namespace System { std::unique_ptr ServiceMenu::instance; void ServiceMenu::init(Rendering::Renderer* renderer) { instance.reset(new ServiceMenu(renderer)); } void ServiceMenu::destroy() { instance.reset(); } auto ServiceMenu::get() -> ServiceMenu* { return instance.get(); } ServiceMenu::ServiceMenu(Rendering::Renderer* renderer) : renderer_(renderer), text_(renderer) {} auto ServiceMenu::isOpen() const -> bool { return open_; } void ServiceMenu::toggle() { if (!open_) { open_ = true; closing_ = false; open_anim_ = 0.0F; animated_h_ = 0.0F; highlight_snap_ = true; // primera frame: enganxar el highlight al cursor buildRootPage(); playAcceptSound(); return; } // Ja obert: iniciem tancament. open_ es mante a true fins que l'animacio // arriba a 0, per a permetre que update() segueixi avançant open_anim_. closing_ = true; playAcceptSound(); } void ServiceMenu::buildRootPage() { Page root; root.title_key = "service_menu.title"; root.items = { Item{.kind = Kind::SUBMENU, .label_key = "service_menu.video", .selectable = true, .on_activate = [this] { pushSubmenuPlaceholder("service_menu.video"); }}, Item{.kind = Kind::SUBMENU, .label_key = "service_menu.audio", .selectable = true, .on_activate = [this] { pushSubmenuPlaceholder("service_menu.audio"); }}, Item{.kind = Kind::SUBMENU, .label_key = "service_menu.options", .selectable = true, .on_activate = [this] { pushSubmenuPlaceholder("service_menu.options"); }}, Item{.kind = Kind::SUBMENU, .label_key = "service_menu.system", .selectable = true, .on_activate = [this] { pushSubmenuPlaceholder("service_menu.system"); }}, }; stack_.clear(); stack_.push_back(std::move(root)); } void ServiceMenu::pushSubmenuPlaceholder(const std::string& title_key) { Page page; page.title_key = title_key; // items buit: el submenu mostra nomes el titol (al·iteracions futures // s'omplen amb opcions reals de Vsync/Zoom/Locale/Restart/Exit/etc.). pushPage(std::move(page)); } void ServiceMenu::pushPage(Page page) { stack_.push_back(std::move(page)); // El cursor salta a una pagina nova: enganxem el highlight per a // evitar que vagi lliscant des de la posicio anterior. highlight_snap_ = true; } void ServiceMenu::popPage() { if (stack_.size() <= 1) { // Estem a la pagina arrel: BACKSPACE tanca el menu. closing_ = true; playAcceptSound(); return; } stack_.pop_back(); highlight_snap_ = true; playAcceptSound(); } void ServiceMenu::moveCursor(int direction) { if (stack_.empty()) { return; } Page& page = stack_.back(); const std::size_t N = page.items.size(); if (N == 0) { return; } // Cerca el seguent item seleccionable amb wrap-around. 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 (page.items[idx].selectable) { if (idx != page.cursor) { page.cursor = idx; playSelectSound(); } return; } } } void ServiceMenu::activateCurrent() { if (stack_.empty()) { return; } const Page& page = stack_.back(); if (page.cursor >= page.items.size()) { return; } const Item& item = page.items[page.cursor]; if (!item.selectable) { return; } if (item.on_activate) { item.on_activate(); // SUBMENU/ACTION reprodueixen accept; els toggles futurs ho // gestionaran als seus propis callbacks si volen un altre so. playAcceptSound(); } } auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool { if (!open_ || stack_.empty() || event.type != SDL_EVENT_KEY_DOWN) { return false; } switch (event.key.scancode) { case SDL_SCANCODE_UP: moveCursor(-1); return true; case SDL_SCANCODE_DOWN: moveCursor(+1); return true; case SDL_SCANCODE_RETURN: case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_RIGHT: activateCurrent(); return true; case SDL_SCANCODE_BACKSPACE: case SDL_SCANCODE_LEFT: popPage(); return true; default: return false; } } auto ServiceMenu::computeTargetHeight() const -> float { if (stack_.empty()) { return 0.0F; } using namespace Defaults::ServiceMenu; const Page& page = stack_.back(); int h = GAP_Y; // padding superior h += TITLE_HEIGHT + GAP_Y; // titol + gap h += SEPARATOR_HEIGHT + GAP_Y; // separador + gap const auto N = static_cast(page.items.size()); if (N > 0) { h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y; } return static_cast(h); } auto ServiceMenu::computeItemTopY(float box_y, std::size_t index) -> float { using namespace Defaults::ServiceMenu; const float ITEMS_Y0 = box_y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT) + static_cast(GAP_Y) + static_cast(SEPARATOR_HEIGHT) + static_cast(GAP_Y); return ITEMS_Y0 + (static_cast(index) * static_cast(ITEM_HEIGHT + ITEM_GAP_Y)); } void ServiceMenu::update(float delta_time) { if (!open_) { return; } using namespace Defaults::ServiceMenu; if (closing_) { open_anim_ -= CLOSE_SPEED * delta_time; if (open_anim_ <= 0.0F) { open_anim_ = 0.0F; animated_h_ = 0.0F; open_ = false; closing_ = false; stack_.clear(); return; } } else { open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * delta_time)); } // Smoothing exponencial cap a l'alçada objectiu de la pagina superior. const float TARGET = closing_ ? 0.0F : computeTargetHeight(); const float ALPHA = 1.0F - std::exp(-HEIGHT_RATE * delta_time); animated_h_ += (TARGET - animated_h_) * ALPHA; // Highlight: lerp cap a la Y de l'item del cursor. Snapping nomes en // obrir o canviar de pagina; en moure el cursor amb UP/DOWN, el rect // llisca suaument cap a la nova posicio. if (stack_.empty()) { return; } const Page& page = stack_.back(); if (page.items.empty()) { highlight_snap_ = true; return; } const float BOX_H_TARGET = computeTargetHeight(); const float BOX_Y_TARGET = (CANVAS_H - BOX_H_TARGET) * 0.5F; const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor); const float TARGET_Y = ITEM_TOP - static_cast(HIGHLIGHT_PAD_Y); const float TARGET_H = static_cast(ITEM_HEIGHT) + (2.0F * static_cast(HIGHLIGHT_PAD_Y)); if (highlight_snap_) { highlight_y_ = TARGET_Y; highlight_h_ = TARGET_H; highlight_snap_ = false; } else { const float HL_ALPHA = 1.0F - std::exp(-HIGHLIGHT_RATE * delta_time); highlight_y_ += (TARGET_Y - highlight_y_) * HL_ALPHA; highlight_h_ += (TARGET_H - highlight_h_) * HL_ALPHA; } } namespace { // Dibuixa un rect (BG sombrejat + 4 ticks L als cantons), simulant // un visor sci-fi al voltant de l'item sel·leccionat. void drawHighlightRect(Rendering::Renderer* renderer, float x, float y, float w, float h) { using namespace Defaults::ServiceMenu; if (w <= 0.0F || h <= 0.0F) { return; } // Wash de fons translucid. fillRect(renderer, x, y, w, h, HIGHLIGHT_FILL); const auto T = static_cast(HIGHLIGHT_THICKNESS); const auto L = static_cast(HIGHLIGHT_TICK_LEN); // Top-left fillRect(renderer, x, y, L, T, HIGHLIGHT_OUTLINE); fillRect(renderer, x, y, T, L, HIGHLIGHT_OUTLINE); // Top-right fillRect(renderer, x + w - L, y, L, T, HIGHLIGHT_OUTLINE); fillRect(renderer, x + w - T, y, T, L, HIGHLIGHT_OUTLINE); // Bottom-left fillRect(renderer, x, y + h - T, L, T, HIGHLIGHT_OUTLINE); fillRect(renderer, x, y + h - L, T, L, HIGHLIGHT_OUTLINE); // Bottom-right fillRect(renderer, x + w - L, y + h - T, L, T, HIGHLIGHT_OUTLINE); fillRect(renderer, x + w - T, y + h - L, T, L, HIGHLIGHT_OUTLINE); } // Brackets als 4 cantons de la caixa (sci-fi HUD). Substitueix la vora // completa per un marc obert. void drawCornerBrackets(Rendering::Renderer* renderer, float x, float y, float w, float h) { using namespace Defaults::ServiceMenu; const auto T = static_cast(CORNER_THICKNESS); const auto AH = static_cast(CORNER_ARM_H); const auto AV = static_cast(CORNER_ARM_V); // Top-left fillRect(renderer, x, y, AH, T, CORNER_COLOR); fillRect(renderer, x, y, T, AV, CORNER_COLOR); // Top-right fillRect(renderer, x + w - AH, y, AH, T, CORNER_COLOR); fillRect(renderer, x + w - T, y, T, AV, CORNER_COLOR); // Bottom-left fillRect(renderer, x, y + h - T, AH, T, CORNER_COLOR); fillRect(renderer, x, y + h - AV, T, AV, CORNER_COLOR); // Bottom-right fillRect(renderer, x + w - AH, y + h - T, AH, T, CORNER_COLOR); fillRect(renderer, x + w - T, y + h - AV, T, AV, CORNER_COLOR); } } // namespace void ServiceMenu::draw() const { if (!open_ || stack_.empty() || renderer_ == nullptr) { return; } using namespace Defaults::ServiceMenu; // Alçada final: smoothing × easing. easeOutQuad afegeix la sensacio // de "snap" al final de l'obertura i l'inici del tancament. const float EASED = easeOutQuad(open_anim_); const float BOX_H = animated_h_ * EASED; if (BOX_H < 1.0F) { return; } const float BOX_X = (CANVAS_W - static_cast(BOX_WIDTH)) * 0.5F; const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F; const auto BOX_W = static_cast(BOX_WIDTH); const float CENTER_X = BOX_X + (BOX_W * 0.5F); // Fons semi-transparent. fillRect(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H, BG_COLOR); // Brackets als cantons (substitueixen la vora completa). drawCornerBrackets(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H); // Clip interior per a tallar text que sortiria del cuadre durant // l'animacio open/close. Marge generos perquè no es mengi els brackets. const int CLIP_X = static_cast(BOX_X + static_cast(CORNER_THICKNESS)); const int CLIP_Y = static_cast(BOX_Y + static_cast(CORNER_THICKNESS)); const int CLIP_W = static_cast(BOX_W - (2.0F * static_cast(CORNER_THICKNESS))); const int CLIP_H = std::max(0, static_cast(BOX_H - (2.0F * static_cast(CORNER_THICKNESS)))); renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H); const Page& page = stack_.back(); // Titol centrat al cim de la caixa. const std::string TITLE = Locale::get().text(page.title_key); const float TITLE_CY = BOX_Y + static_cast(GAP_Y) + (static_cast(TITLE_HEIGHT) * 0.5F); text_.renderCentered(TITLE, Vec2{.x = CENTER_X, .y = TITLE_CY}, TITLE_SCALE, TEXT_SPACING, 1.0F, TITLE_COLOR); // Separador horitzontal sota el titol. const float SEP_Y = BOX_Y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT) + (static_cast(GAP_Y) * 0.5F); fillRect(renderer_, BOX_X + static_cast(GAP_Y), SEP_Y, BOX_W - (2.0F * static_cast(GAP_Y)), static_cast(SEPARATOR_HEIGHT), SEPARATOR_COLOR); // Highlight rect: nomes si la pagina te items i el rect te alçada. if (!page.items.empty() && highlight_h_ > 0.0F) { const float HL_X = BOX_X + static_cast(HIGHLIGHT_PAD_X); const float HL_W = BOX_W - (2.0F * static_cast(HIGHLIGHT_PAD_X)); drawHighlightRect(renderer_, HL_X, highlight_y_, HL_W, highlight_h_); } // Llista d'items: centrats horitzontalment, color groc per al seleccionat. for (std::size_t i = 0; i < page.items.size(); ++i) { const Item& item = page.items[i]; const SDL_Color COL = (i == page.cursor) ? CURSOR_COLOR : LABEL_COLOR; const std::string TEXT = Locale::get().text(item.label_key); const float ITEM_TOP = computeItemTopY(BOX_Y, i); const float ITEM_CY = ITEM_TOP + (static_cast(ITEM_HEIGHT) * 0.5F); text_.renderCentered(TEXT, Vec2{.x = CENTER_X, .y = ITEM_CY}, ITEM_SCALE, TEXT_SPACING, 1.0F, COL); } renderer_->popClip(); } } // namespace System