// service_menu.cpp - Implementacio del menu de servei // © 2026 JailDesigner #include "core/system/service_menu.hpp" #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/config/engine_config.hpp" #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" #include "core/types.hpp" #include "game/config_yaml.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, SDLManager* sdl) { instance.reset(new ServiceMenu(renderer, sdl)); } void ServiceMenu::destroy() { instance.reset(); } auto ServiceMenu::get() -> ServiceMenu* { return instance.get(); } ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl) : renderer_(renderer), sdl_(sdl), 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(); } namespace { // Helper local: construeix un item de tipus SUBMENU amb el callback // d'entrada. Es manté local a aquesta TU per a poder construir la // pagina arrel a buildRootPage sense designed-initializers parcials // (clang-tidy es queixa quan en falten). auto makeSubmenu(const std::string& label_key, std::function on_activate) -> ServiceMenu::Item { return ServiceMenu::Item{ .kind = ServiceMenu::Kind::SUBMENU, .label_key = label_key, .selectable = true, .on_activate = std::move(on_activate), .get_value_text = {}, .on_change = {}, }; } } // namespace void ServiceMenu::buildRootPage() { Page root; root.title_key = "service_menu.title"; root.items = { makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), makeSubmenu("service_menu.audio", [this] { pushSubmenuPlaceholder("service_menu.audio"); }), makeSubmenu("service_menu.options", [this] { pushSubmenuPlaceholder("service_menu.options"); }), makeSubmenu("service_menu.system", [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)); } auto ServiceMenu::buildVideoPage() const -> Page { // Helper: localitza ON/OFF per a TOGGLE items. auto on_off_text = [](bool v) -> std::string { return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); }; SDLManager* sdl = sdl_; Page page; page.title_key = "service_menu.video"; page.items = { // ZOOM (INT_RANGE-style: ± delega a sdl.increase/decreaseWindowSize). Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.video_zoom", .selectable = true, .on_activate = {}, .get_value_text = [sdl] { return std::format("{:.1f}X", sdl->getScaleFactor()); }, .on_change = [sdl](int dir) { if (dir > 0) { sdl->increaseWindowSize(); } else { sdl->decreaseWindowSize(); } }, }, // FULLSCREEN Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_fullscreen", .selectable = true, .on_activate = {}, .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isFullscreen()); }, .on_change = [sdl](int) { sdl->toggleFullscreen(); }, }, // VSYNC Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_vsync", .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.vsync != 0); }, .on_change = [sdl](int) { sdl->toggleVSync(); }, }, // ANTIALIAS Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_aa", .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.antialias != 0); }, .on_change = [sdl](int) { sdl->toggleAntialias(); }, }, // POSTPROCESSAT Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_postfx", .selectable = true, .on_activate = {}, .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isPostFxEnabled()); }, .on_change = [sdl](int) { sdl->togglePostFx(); }, }, }; return 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() { // ENTER = canvi de valor cap endavant (equivalent a RIGHT). Per a // SUBMENU/ACTION entra/activa; per a TOGGLE/CYCLE/INT_RANGE incrementa. changeValue(+1); } void ServiceMenu::changeValue(int direction) { 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; } switch (item.kind) { case Kind::TOGGLE: case Kind::CYCLE: case Kind::INT_RANGE: if (item.on_change) { item.on_change(direction); playAcceptSound(); } break; case Kind::SUBMENU: case Kind::ACTION: // Nomes +1 entra/activa: LEFT no fa res (BACKSPACE per a sortir). if (direction > 0 && item.on_activate) { item.on_activate(); playAcceptSound(); } break; case Kind::LABEL: break; } } 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: activateCurrent(); return true; case SDL_SCANCODE_RIGHT: changeValue(+1); return true; case SDL_SCANCODE_LEFT: changeValue(-1); return true; case SDL_SCANCODE_BACKSPACE: 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. // - Items amb valor (TOGGLE/CYCLE/INT_RANGE): label esquerra + valor dreta dins del highlight. // - Items sense valor (SUBMENU/ACTION/LABEL): label centrat. const float HL_LEFT = BOX_X + static_cast(HIGHLIGHT_PAD_X); const float HL_RIGHT = BOX_X + BOX_W - static_cast(HIGHLIGHT_PAD_X); const float TEXT_TOP_OFFSET = Graphics::VectorText::getTextHeight(ITEM_SCALE) * 0.5F; 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 LABEL = 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); if (item.get_value_text) { // Layout dues columnes: label esquerra, valor dreta. const std::string VALUE = item.get_value_text(); const float TEXT_TOP_Y = ITEM_CY - TEXT_TOP_OFFSET; const float VALUE_W = Graphics::VectorText::getTextWidth(VALUE, ITEM_SCALE, TEXT_SPACING); text_.render(LABEL, Vec2{.x = HL_LEFT + static_cast(TEXT_INSET_X), .y = TEXT_TOP_Y}, ITEM_SCALE, TEXT_SPACING, 1.0F, COL); text_.render(VALUE, Vec2{.x = HL_RIGHT - static_cast(TEXT_INSET_X) - VALUE_W, .y = TEXT_TOP_Y}, ITEM_SCALE, TEXT_SPACING, 1.0F, COL); } else { // Layout simple: label centrat. text_.renderCentered(LABEL, Vec2{.x = CENTER_X, .y = ITEM_CY}, ITEM_SCALE, TEXT_SPACING, 1.0F, COL); } } renderer_->popClip(); } } // namespace System