// service_menu.cpp - Implementacio del menu de servei // © 2026 JailDesigner #include "core/system/service_menu.hpp" #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/config/engine_config.hpp" #include "core/defaults/audio.hpp" #include "core/defaults/rendering.hpp" #include "core/defaults/service_menu.hpp" #include "core/input/define_inputs.hpp" #include "core/input/input.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" #include "core/system/debug_overlay.hpp" #include "core/system/notifier.hpp" #include "core/system/relaunch.hpp" #include "core/types.hpp" #include "core/utils/string_utils.hpp" #include "game/config_yaml.hpp" #include "project.h" 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); } } // Resol el text del label d'un item: prioritza label_text (literal) sobre // label_key (locale). Retorna cadena buida si tots dos son buits. auto resolveLabel(const System::ServiceMenu::Item& item) -> std::string { if (!item.label_text.empty()) { return item.label_text; } if (item.label_key.empty()) { return {}; } return Locale::get().text(item.label_key); } // ---- Helpers de la pagina CONTROLS ---- auto padDisplayName(int player_index) -> std::string { const auto* input = Input::get(); if (input == nullptr) { return Locale::get().text("service_menu.controls_no_pad"); } auto pad = input->getPlayerGamepad(player_index); if (!pad) { return Locale::get().text("service_menu.controls_no_pad"); } return Utils::toUpperAscii(pad->name); } // Index actual del pad assignat dins de la llista de mandos detectats. // Prioritat path > name. Si no n'hi ha cap match, retorna 0. auto findAssignedIndex(const std::vector>& pads, const Config::PlayerBindings& pcfg) -> std::size_t { for (std::size_t i = 0; i < pads.size(); ++i) { if (pads[i] && !pcfg.gamepad_path.empty() && pads[i]->path == pcfg.gamepad_path) { return i; } } for (std::size_t i = 0; i < pads.size(); ++i) { if (pads[i] && !pcfg.gamepad_name.empty() && pads[i]->name == pcfg.gamepad_name) { return i; } } return 0; } // Avança ciclicament l'assignacio de pad d'un jugador i la persisteix. void cyclePlayerPad(int player_index, int dir) { auto* input = Input::get(); if (input == nullptr) { return; } const auto& pads = input->getGamepads(); if (pads.empty()) { return; } auto& pcfg = (player_index == 0) ? ConfigYaml::engine_config.player1 : ConfigYaml::engine_config.player2; const std::size_t CURRENT = findAssignedIndex(pads, pcfg); const std::size_t N = pads.size(); const std::size_t STEP = (dir > 0) ? 1 : (N - 1); const std::size_t NEXT = (CURRENT + STEP) % N; if (!pads[NEXT]) { return; } pcfg.gamepad_name = pads[NEXT]->name; pcfg.gamepad_path = pads[NEXT]->path; if (player_index == 0) { input->applyPlayer1Bindings(ConfigYaml::engine_config.player1); } else { input->applyPlayer2Bindings(ConfigYaml::engine_config.player2); } ConfigYaml::saveToFile(); } // Arranca un rebind o avisa si el jugador no te pad. Retorna true si el // rebind ha començat (el caller ha de tancar el menu). auto startDefine(System::DefineInputs::Mode mode, System::DefineInputs::Player pl) -> bool { auto* di = System::DefineInputs::get(); if (di == nullptr) { return false; } if (!di->begin(mode, pl)) { if (auto* n = System::Notifier::get(); n != nullptr) { n->notifyWarn(Locale::get().text("define.no_gamepad")); } return false; } return true; } } // namespace namespace System { std::unique_ptr ServiceMenu::instance; void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) { instance.reset(new ServiceMenu(renderer, sdl, debug_overlay)); } void ServiceMenu::destroy() { instance.reset(); } auto ServiceMenu::get() -> ServiceMenu* { return instance.get(); } ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) : renderer_(renderer), sdl_(sdl), debug_overlay_(debug_overlay), 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(); // L'ample comença ja al valor objectiu (la caixa surt amb l'amplada // final i nomes anima l'alçada). L'ample s'animarà despres entre // pagines (push/pop). animated_w_ = computeTargetWidth(); 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, .label_text = {}, .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] { pushPage(buildAudioPage()); }), makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }), makeSubmenu("service_menu.controls", [this] { pushPage(buildControlsPage()); }), makeSubmenu("service_menu.system", [this] { pushPage(buildSystemPage()); }), }; stack_.clear(); stack_.push_back(std::move(root)); } auto ServiceMenu::buildVideoPage() -> 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", .label_text = {}, .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", .label_text = {}, .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", .label_text = {}, .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(); }, }, // RESOLUCIO (sub-submenu amb els 5 presets; mostra l'actual com a valor) Item{ .kind = Kind::SUBMENU, .label_key = "service_menu.video_resolution", .label_text = {}, .selectable = true, .on_activate = [this] { pushPage(buildResolutionPage()); }, .get_value_text = [] { return std::format("{}X{}", ConfigYaml::engine_config.rendering.render_width, ConfigYaml::engine_config.rendering.render_height); }, .on_change = {}, }, // ANTIALIAS Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_aa", .label_text = {}, .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", .label_text = {}, .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; } auto ServiceMenu::buildResolutionPage() const -> Page { Page page; page.title_key = "service_menu.video_resolution"; // El cursor arrenca sobre el preset actual perquè l'usuari vegi quin // esta seleccionat sense buscar-lo. const int CURR_W = ConfigYaml::engine_config.rendering.render_width; const int CURR_H = ConfigYaml::engine_config.rendering.render_height; std::size_t cursor = 0; SDLManager* sdl = sdl_; for (std::size_t i = 0; i < Defaults::Rendering::RESOLUTION_PRESETS.size(); ++i) { const auto& preset = Defaults::Rendering::RESOLUTION_PRESETS[i]; if (preset.w == CURR_W && preset.h == CURR_H) { cursor = i; } const int PW = preset.w; const int PH = preset.h; page.items.push_back(Item{ .kind = Kind::ACTION, .label_key = {}, .label_text = std::format("{}X{}", PW, PH), .selectable = true, .on_activate = [sdl, PW, PH] { sdl->setRenderResolution(PW, PH); }, .get_value_text = {}, .on_change = {}, }); } page.cursor = cursor; return page; } auto ServiceMenu::buildAudioPage() -> Page { auto on_off_text = [](bool v) -> std::string { return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); }; // Aplica un step de volum (±VOLUME_STEP) a un valor 0..1 i retorna el // resultat clampat. El motor s'encarrega d'aplicar-lo amb el getter. auto step_volume = [](float current, int dir) -> float { const float STEP = Defaults::Audio::VOLUME_STEP; return std::clamp(current + (static_cast(dir) * STEP), 0.0F, 1.0F); }; Page page; page.title_key = "service_menu.audio"; page.items = { // AUDIO (master ON/OFF) Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_master", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { const Audio* a = Audio::get(); return on_off_text(a != nullptr && a->isEnabled()); }, .on_change = [](int) { if (auto* a = Audio::get(); a != nullptr) { a->toggleEnabled(); ConfigYaml::engine_config.audio.enabled = a->isEnabled(); ConfigYaml::saveToFile(); } }, }, // VOLUM GENERAL (master) Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_master_volume", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { const Audio* a = Audio::get(); const float V = (a != nullptr) ? a->getMasterVolume() : 0.0F; return std::to_string(Audio::toPercent(V)); }, .on_change = [step_volume](int dir) { if (auto* a = Audio::get(); a != nullptr) { a->setMasterVolume(step_volume(a->getMasterVolume(), dir)); ConfigYaml::engine_config.audio.volume = a->getMasterVolume(); ConfigYaml::saveToFile(); } }, }, // MUSICA ON/OFF Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_music", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { const Audio* a = Audio::get(); return on_off_text(a != nullptr && a->isMusicEnabled()); }, .on_change = [](int) { if (auto* a = Audio::get(); a != nullptr) { a->toggleMusic(); ConfigYaml::engine_config.audio.music_enabled = a->isMusicEnabled(); ConfigYaml::saveToFile(); } }, }, // VOLUM MUSICA Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_music_volume", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { const Audio* a = Audio::get(); const float V = (a != nullptr) ? a->getMusicVolume() : 0.0F; return std::to_string(Audio::toPercent(V)); }, .on_change = [step_volume](int dir) { if (auto* a = Audio::get(); a != nullptr) { a->setMusicVolume(step_volume(a->getMusicVolume(), dir)); ConfigYaml::engine_config.audio.music_volume = a->getMusicVolume(); ConfigYaml::saveToFile(); } }, }, // SONS ON/OFF Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_sound", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { const Audio* a = Audio::get(); return on_off_text(a != nullptr && a->isSoundEnabled()); }, .on_change = [](int) { if (auto* a = Audio::get(); a != nullptr) { a->toggleSound(); ConfigYaml::engine_config.audio.sound_enabled = a->isSoundEnabled(); ConfigYaml::saveToFile(); } }, }, // VOLUM SONS Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_sound_volume", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { const Audio* a = Audio::get(); const float V = (a != nullptr) ? a->getSoundVolume() : 0.0F; return std::to_string(Audio::toPercent(V)); }, .on_change = [step_volume](int dir) { if (auto* a = Audio::get(); a != nullptr) { a->setSoundVolume(step_volume(a->getSoundVolume(), dir)); ConfigYaml::engine_config.audio.sound_volume = a->getSoundVolume(); ConfigYaml::saveToFile(); } }, }, }; return page; } auto ServiceMenu::buildOptionsPage() const -> Page { auto on_off_text = [](bool v) -> std::string { return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); }; DebugOverlay* debug = debug_overlay_; Page page; page.title_key = "service_menu.options"; page.items = { // IDIOMA (cycle entre ca i en, mateix codi que F7). Item{ .kind = Kind::CYCLE, .label_key = "service_menu.options_language", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { return Locale::get().text("language." + ConfigYaml::engine_config.locale); }, .on_change = [](int) { const std::string NEW_LANG = (ConfigYaml::engine_config.locale == "ca") ? "en" : "ca"; if (Locale::get().switchTo(NEW_LANG)) { ConfigYaml::engine_config.locale = NEW_LANG; ConfigYaml::saveToFile(); } }, }, // MOSTRAR INFO (debug overlay, equivalent a F11). Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.options_show_info", .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [debug, on_off_text] { return on_off_text(debug != nullptr && debug->isVisible()); }, .on_change = [debug](int) { if (debug != nullptr) { debug->toggle(); } }, }, }; return page; } namespace { auto makeCyclePadItem(const char* label_key, int player_index) -> ServiceMenu::Item { return ServiceMenu::Item{ .kind = ServiceMenu::Kind::CYCLE, .label_key = label_key, .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [player_index] { return padDisplayName(player_index); }, .on_change = [player_index](int dir) { cyclePlayerPad(player_index, dir); }, }; } auto makeDefineItem(const char* label_key, DefineInputs::Mode mode, DefineInputs::Player pl, ServiceMenu* menu) -> ServiceMenu::Item { return ServiceMenu::Item{ .kind = ServiceMenu::Kind::ACTION, .label_key = label_key, .label_text = {}, .selectable = true, .on_activate = [mode, pl, menu] { if (startDefine(mode, pl) && menu != nullptr && menu->isOpen()) { menu->toggle(); } }, .get_value_text = {}, .on_change = {}, }; } } // namespace auto ServiceMenu::buildControlsPage() -> Page { Page page; page.title_key = "service_menu.controls"; page.items = { makeCyclePadItem("service_menu.controls_pad_p1", 0), makeCyclePadItem("service_menu.controls_pad_p2", 1), makeDefineItem("service_menu.controls_define_keyboard_p1", DefineInputs::Mode::KEYBOARD, DefineInputs::Player::P1, this), makeDefineItem("service_menu.controls_define_keyboard_p2", DefineInputs::Mode::KEYBOARD, DefineInputs::Player::P2, this), makeDefineItem("service_menu.controls_define_gamepad_p1", DefineInputs::Mode::GAMEPAD, DefineInputs::Player::P1, this), makeDefineItem("service_menu.controls_define_gamepad_p2", DefineInputs::Mode::GAMEPAD, DefineInputs::Player::P2, this), }; return page; } auto ServiceMenu::buildSystemPage() -> Page { Page page; page.title_key = "service_menu.system"; // Versio + hash com a subtitol sota el titol (apagat, mes petit). // Uppercase del hash perque VectorText nomes admet majuscules. page.subtitle_provider = [] { return std::format("V{} - {}", Project::VERSION, Utils::toUpperAscii(Project::GIT_HASH)); }; page.items = { // REINICIAR (amb confirmacio). Item{ .kind = Kind::ACTION, .label_key = "service_menu.system_restart", .label_text = {}, .selectable = true, .on_activate = [this] { pushConfirmPage("service_menu.confirm_restart", [] { System::Relaunch::request(); SDL_Event quit_event{}; quit_event.type = SDL_EVENT_QUIT; SDL_PushEvent(&quit_event); }); }, .get_value_text = {}, .on_change = {}, }, // EIXIR DEL JOC (amb confirmacio). Item{ .kind = Kind::ACTION, .label_key = "service_menu.exit", .label_text = {}, .selectable = true, .on_activate = [this] { pushConfirmPage("service_menu.confirm_exit", [] { SDL_Event quit_event{}; quit_event.type = SDL_EVENT_QUIT; SDL_PushEvent(&quit_event); }); }, .get_value_text = {}, .on_change = {}, }, }; return page; } void ServiceMenu::pushConfirmPage(const std::string& title_key, std::function on_yes) { auto yes_callback = std::move(on_yes); Page page; page.title_key = title_key; page.cursor = 0; // per defecte sobre NO (segur) page.items = { Item{ .kind = Kind::ACTION, .label_key = "service_menu.confirm_no", .label_text = {}, .selectable = true, .on_activate = [this] { popPage(); }, .get_value_text = {}, .on_change = {}, }, Item{ .kind = Kind::ACTION, .label_key = "service_menu.confirm_yes", .label_text = {}, .selectable = true, .on_activate = std::move(yes_callback), .get_value_text = {}, .on_change = {}, }, }; 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() { // 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; // titol if (page.subtitle_provider) { h += GAP_Y / 2 + SUBTITLE_HEIGHT; // subtitol amb mig gap } h += GAP_Y; // gap abans del separador 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::computeTargetWidth() const -> float { using namespace Defaults::ServiceMenu; if (stack_.empty()) { return static_cast(BOX_WIDTH_MIN); } const Page& page = stack_.back(); // Comencem amb l'ample del titol. float content_w = Graphics::VectorText::getTextWidth( Locale::get().text(page.title_key), TITLE_SCALE, TEXT_SPACING); if (page.subtitle_provider) { content_w = std::max(content_w, Graphics::VectorText::getTextWidth(page.subtitle_provider(), SUBTITLE_SCALE, TEXT_SPACING)); } for (const Item& item : page.items) { const std::string LABEL = resolveLabel(item); if (LABEL.empty() && item.get_value_text) { content_w = std::max(content_w, Graphics::VectorText::getTextWidth(item.get_value_text(), ITEM_SCALE, TEXT_SPACING)); } else if (item.get_value_text) { const float LABEL_W = Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING); const float VALUE_W = Graphics::VectorText::getTextWidth( item.get_value_text(), ITEM_SCALE, TEXT_SPACING); content_w = std::max(content_w, LABEL_W + static_cast(MIN_LABEL_VALUE_GAP) + VALUE_W); } else { content_w = std::max(content_w, Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING)); } } // Padding total: highlight pad als dos costats + inset del text. const float REQUIRED = content_w + (2.0F * static_cast(HIGHLIGHT_PAD_X)) + (2.0F * static_cast(TEXT_INSET_X)); return std::max(static_cast(BOX_WIDTH_MIN), REQUIRED); } auto ServiceMenu::computeItemTopY(float box_y, std::size_t index, bool has_subtitle) -> float { using namespace Defaults::ServiceMenu; float items_y0 = box_y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT); if (has_subtitle) { items_y0 += static_cast(GAP_Y / 2) + static_cast(SUBTITLE_HEIGHT); } items_y0 += 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 i ample objectius de la pagina. const float TARGET_H_BOX = closing_ ? 0.0F : computeTargetHeight(); const float TARGET_W_BOX = closing_ ? animated_w_ : computeTargetWidth(); const float ALPHA_H = 1.0F - std::exp(-HEIGHT_RATE * delta_time); const float ALPHA_W = 1.0F - std::exp(-WIDTH_RATE * delta_time); animated_h_ += (TARGET_H_BOX - animated_h_) * ALPHA_H; animated_w_ += (TARGET_W_BOX - animated_w_) * ALPHA_W; // 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 bool HAS_SUBTITLE = static_cast(page.subtitle_provider); const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor, HAS_SUBTITLE); 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_W = animated_w_; const float BOX_X = (CANVAS_W - BOX_W) * 0.5F; const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F; 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(); const bool HAS_SUBTITLE = static_cast(page.subtitle_provider); // 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); // Subtitol opcional: sota el titol, mes petit i apagat. if (HAS_SUBTITLE) { const float SUBTITLE_CY = BOX_Y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT) + (static_cast(GAP_Y) / 4.0F) + (static_cast(SUBTITLE_HEIGHT) * 0.5F); text_.renderCentered(page.subtitle_provider(), Vec2{.x = CENTER_X, .y = SUBTITLE_CY}, SUBTITLE_SCALE, TEXT_SPACING, 1.0F, SUBTITLE_COLOR); } // Separador horitzontal sota el titol (o subtitol si n'hi ha). float sep_y = BOX_Y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT); if (HAS_SUBTITLE) { sep_y += static_cast(GAP_Y / 2) + static_cast(SUBTITLE_HEIGHT); } sep_y += static_cast(GAP_Y) * 0.5F; const float SEP_Y = sep_y; 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; // resolveLabel prioritza label_text (literal) sobre label_key (locale). const std::string LABEL = resolveLabel(item); const float ITEM_TOP = computeItemTopY(BOX_Y, i, HAS_SUBTITLE); const float ITEM_CY = ITEM_TOP + (static_cast(ITEM_HEIGHT) * 0.5F); if (LABEL.empty() && item.get_value_text) { // Item nomes-valor (sense label): el text del valor es // renderitza centrat com a label decoratiu. Util per a items // d'informacio com la versio/hash a SISTEMA. text_.renderCentered(item.get_value_text(), Vec2{.x = CENTER_X, .y = ITEM_CY}, ITEM_SCALE, TEXT_SPACING, 1.0F, COL); } else 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