From 10a3e2fedd52da4e88cbcfcb82b9731fcef69191 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 14 Apr 2026 19:41:17 +0200 Subject: [PATCH] =?UTF-8?q?service=20menu=20vitaminat:=20cliping,=20swappi?= =?UTF-8?q?ng=20animation=20i=20versi=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/ui/menu_renderer.cpp | 90 +++++++++++++++++++++++++++++--- source/game/ui/menu_renderer.hpp | 19 +++++++ source/game/ui/service_menu.cpp | 16 ++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/source/game/ui/menu_renderer.cpp b/source/game/ui/menu_renderer.cpp index 910c482..bd3cca7 100644 --- a/source/game/ui/menu_renderer.cpp +++ b/source/game/ui/menu_renderer.cpp @@ -7,8 +7,10 @@ #include "core/rendering/text.hpp" #include "game/ui/menu_option.hpp" #include "utils/color.hpp" +#include "utils/defines.hpp" // Para Texts::VERSION #include "utils/param.hpp" #include "utils/utils.hpp" +#include "version.h" // Para Version::GIT_HASH // --- Implementación de las estructuras de animación --- @@ -74,6 +76,17 @@ void MenuRenderer::render(const ServiceMenu* menu_state) { // Solo renderizar contenido si la animación lo permite if (shouldShowContent()) { + // Recorta todas las operaciones de texto y líneas al rect actual del panel + // para que durante el resize animation nada se pinte fuera del borde. + auto* renderer = Screen::get()->getRenderer(); + const SDL_Rect CLIP_RECT = { + .x = static_cast(rect_.x), + .y = static_cast(rect_.y), + .w = static_cast(rect_.w), + .h = static_cast(rect_.h), + }; + SDL_SetRenderClipRect(renderer, &CLIP_RECT); + // Dibuja el título float y = rect_.y + title_padding_; title_text_->writeDX(Text::COLOR | Text::CENTER, rect_.x + (rect_.w / 2.0F), y, menu_state->getTitle(), -4, param.service_menu.title_color); @@ -83,27 +96,52 @@ void MenuRenderer::render(const ServiceMenu* menu_state) { SDL_SetRenderDrawColor(Screen::get()->getRenderer(), BORDER_COLOR.r, BORDER_COLOR.g, BORDER_COLOR.b, 255); SDL_RenderLine(Screen::get()->getRenderer(), rect_.x + ServiceMenu::OPTIONS_HORIZONTAL_PADDING, y, rect_.x + rect_.w - ServiceMenu::OPTIONS_HORIZONTAL_PADDING, y); + // Dibuja el subtítulo del grupo (versión + hash en SYSTEM) + if (groupHasSubtitle(menu_state->getCurrentGroup())) { + const std::string SUBTITLE = "ver. " + std::string(Texts::VERSION) + " (" + std::string(Version::GIT_HASH) + ")"; + element_text_->writeDX( + Text::CENTER | Text::COLOR, + rect_.x + (rect_.w / 2.0F), + y + options_padding_, + SUBTITLE, + -2, + param.service_menu.text_color); + } + // Dibuja las opciones y = options_y_; const auto& option_pairs = menu_state->getOptionPairs(); + const float ROW_HEIGHT = static_cast(options_height_ + options_padding_); for (size_t i = 0; i < option_pairs.size(); ++i) { const bool IS_SELECTED = (i == menu_state->getSelectedIndex()); const Color& current_color = IS_SELECTED ? param.service_menu.selected_color : param.service_menu.text_color; + // Offset Y del valor durante la animación de swap: va desde la fila + // origen (la otra implicada) hasta su fila natural con easeOut. + float value_y_offset = 0.0F; + if (swap_animation_.active && (i == swap_animation_.idx_a || i == swap_animation_.idx_b)) { + const size_t OTHER = (i == swap_animation_.idx_a) ? swap_animation_.idx_b : swap_animation_.idx_a; + const float PROGRESS = easeOut(swap_animation_.elapsed / swap_animation_.duration); + const float DELTA_ROWS = static_cast(OTHER) - static_cast(i); + value_y_offset = DELTA_ROWS * ROW_HEIGHT * (1.0F - PROGRESS); + } + if (menu_state->getCurrentGroupAlignment() == ServiceMenu::GroupAlignment::LEFT) { const int AVAILABLE_WIDTH = rect_.w - (ServiceMenu::OPTIONS_HORIZONTAL_PADDING * 2) - element_text_->length(option_pairs.at(i).first, -2) - ServiceMenu::MIN_GAP_OPTION_VALUE; std::string truncated_value = getTruncatedValue(option_pairs.at(i).second, AVAILABLE_WIDTH); element_text_->writeColored(rect_.x + ServiceMenu::OPTIONS_HORIZONTAL_PADDING, y, option_pairs.at(i).first, current_color, -2); const int X = rect_.x + rect_.w - ServiceMenu::OPTIONS_HORIZONTAL_PADDING - element_text_->length(truncated_value, -2); - element_text_->writeColored(X, y, truncated_value, current_color, -2); + element_text_->writeColored(X, static_cast(y + value_y_offset), truncated_value, current_color, -2); } else { const int AVAILABLE_WIDTH = rect_.w - (ServiceMenu::OPTIONS_HORIZONTAL_PADDING * 2); std::string truncated_caption = getTruncatedValue(option_pairs.at(i).first, AVAILABLE_WIDTH); element_text_->writeDX(Text::CENTER | Text::COLOR, rect_.x + (rect_.w / 2.0F), y, truncated_caption, -2, current_color); } - y += options_height_ + options_padding_; + y += ROW_HEIGHT; } + + SDL_SetRenderClipRect(renderer, nullptr); } } @@ -201,7 +239,8 @@ auto MenuRenderer::calculateNewRect(const ServiceMenu* menu_state) -> SDL_FRect width_ = std::min(static_cast(getMenuWidthForGroup(menu_state->getCurrentGroup())), max_menu_width_); const auto& display_options = menu_state->getDisplayOptions(); lower_height_ = ((!display_options.empty() ? display_options.size() - 1 : 0) * (options_height_ + options_padding_)) + options_height_ + (lower_padding_ * 2); - height_ = std::min(upper_height_ + lower_height_, max_menu_height_); + subtitle_offset_ = getGroupSubtitleHeight(menu_state); + height_ = std::min(upper_height_ + subtitle_offset_ + lower_height_, max_menu_height_); SDL_FRect new_rect = {.x = 0, .y = 0, .w = static_cast(width_), .h = static_cast(height_)}; @@ -209,6 +248,18 @@ auto MenuRenderer::calculateNewRect(const ServiceMenu* menu_state) -> SDL_FRect return new_rect; } +// Grupos que muestran una línea de subtítulo bajo el título (antes de las opciones). +auto MenuRenderer::groupHasSubtitle(ServiceMenu::SettingsGroup group) -> bool { + return group == ServiceMenu::SettingsGroup::SYSTEM; +} + +// Altura extra reservada para el subtítulo del grupo actual. +// Reservamos el alto de una línea de element_text_ más un pequeño padding. +auto MenuRenderer::getGroupSubtitleHeight(const ServiceMenu* menu_state) const -> size_t { + if (!groupHasSubtitle(menu_state->getCurrentGroup())) { return 0; } + return element_text_->getCharacterSize() + options_padding_; +} + void MenuRenderer::resize(const ServiceMenu* menu_state) { SDL_FRect new_rect = calculateNewRect(menu_state); @@ -219,7 +270,7 @@ void MenuRenderer::resize(const ServiceMenu* menu_state) { // Si no hay cambio de tamaño, solo actualizamos la posición updatePosition(); } - options_y_ = rect_.y + upper_height_ + lower_padding_; + options_y_ = rect_.y + upper_height_ + subtitle_offset_ + lower_padding_; } void MenuRenderer::setSize(const ServiceMenu* menu_state) { @@ -231,16 +282,35 @@ void MenuRenderer::setSize(const ServiceMenu* menu_state) { resize_animation_.stop(); updatePosition(); - options_y_ = rect_.y + upper_height_ + lower_padding_; + options_y_ = rect_.y + upper_height_ + subtitle_offset_ + lower_padding_; border_rect_ = {.x = rect_.x - 1, .y = rect_.y + 1, .w = rect_.w + 2, .h = rect_.h - 2}; } // --- Métodos de animación y posición --- +void MenuRenderer::startSwapAnimation(size_t idx_a, size_t idx_b) { + if (idx_a == idx_b) { return; } + swap_animation_.active = true; + swap_animation_.idx_a = idx_a; + swap_animation_.idx_b = idx_b; + swap_animation_.elapsed = 0.0F; +} + +void MenuRenderer::updateSwapAnimation(float delta_time) { + swap_animation_.elapsed += delta_time; + if (swap_animation_.elapsed >= swap_animation_.duration) { + swap_animation_.active = false; + swap_animation_.elapsed = 0.0F; + } +} + void MenuRenderer::updateAnimations(float delta_time) { if (show_hide_animation_.active) { updateShowHideAnimation(delta_time); } + if (swap_animation_.active) { + updateSwapAnimation(delta_time); + } if (resize_animation_.active) { updateResizeAnimation(delta_time); } @@ -272,7 +342,7 @@ void MenuRenderer::updateShowHideAnimation(float delta_time) { } updatePosition(); } - options_y_ = rect_.y + upper_height_ + lower_padding_; + options_y_ = rect_.y + upper_height_ + subtitle_offset_ + lower_padding_; } void MenuRenderer::updateResizeAnimation(float delta_time) { @@ -290,7 +360,7 @@ void MenuRenderer::updateResizeAnimation(float delta_time) { rect_.h = resize_animation_.start_height + ((resize_animation_.target_height - resize_animation_.start_height) * progress); updatePosition(); } - options_y_ = rect_.y + upper_height_ + lower_padding_; + options_y_ = rect_.y + upper_height_ + subtitle_offset_ + lower_padding_; } void MenuRenderer::updatePosition() { @@ -341,6 +411,12 @@ void MenuRenderer::precalculateMenuWidths(const std::vectorgetCurrentGroupAlignment() == ServiceMenu::GroupAlignment::LEFT) { total_width += ServiceMenu::MIN_GAP_OPTION_VALUE + max_value_width; } + // Si el grupo tiene subtítulo, ensanchamos lo necesario para que quepa + if (groupHasSubtitle(sg)) { + const std::string SUBTITLE = "ver. " + std::string(Texts::VERSION) + " (" + std::string(Version::GIT_HASH) + ")"; + const size_t SUBTITLE_WIDTH = static_cast(element_text_->length(SUBTITLE, -2)) + (ServiceMenu::OPTIONS_HORIZONTAL_PADDING * 2); + total_width = std::max(total_width, SUBTITLE_WIDTH); + } group_menu_widths_[group] = std::min(std::max(static_cast(ServiceMenu::MIN_WIDTH), static_cast(total_width)), static_cast(max_menu_width_)); } } diff --git a/source/game/ui/menu_renderer.hpp b/source/game/ui/menu_renderer.hpp index a5dbf67..10d3784 100644 --- a/source/game/ui/menu_renderer.hpp +++ b/source/game/ui/menu_renderer.hpp @@ -42,6 +42,9 @@ class MenuRenderer { void onLayoutChanged(const ServiceMenu* menu_state); void setLayout(const ServiceMenu* menu_state); + // Animación de intercambio de valores entre dos filas del menú actual + void startSwapAnimation(size_t idx_a, size_t idx_b); + // Getters [[nodiscard]] auto getRect() const -> const SDL_FRect& { return rect_; } @@ -63,6 +66,7 @@ class MenuRenderer { size_t upper_height_ = 0; size_t lower_height_ = 0; size_t lower_padding_ = 0; + size_t subtitle_offset_ = 0; // Altura reservada para subtítulo del grupo actual Uint32 color_counter_ = 0; bool visible_ = false; @@ -102,6 +106,16 @@ class MenuRenderer { void stop(); } show_hide_animation_; + // Animación de intercambio: interpola la Y del VALOR de dos filas + // durante su intercambio. El caption se queda estático. + struct SwapAnimation { + bool active = false; + size_t idx_a = 0; + size_t idx_b = 0; + float elapsed = 0.0F; + float duration = 0.4F; + } swap_animation_; + // --- Anchos precalculados --- std::array group_menu_widths_ = {}; @@ -112,9 +126,14 @@ class MenuRenderer { void resize(const ServiceMenu* menu_state); void setSize(const ServiceMenu* menu_state); + // Altura extra ocupada por el subtítulo del grupo actual (versión + hash en SYSTEM) + [[nodiscard]] auto getGroupSubtitleHeight(const ServiceMenu* menu_state) const -> size_t; + [[nodiscard]] static auto groupHasSubtitle(ServiceMenu::SettingsGroup group) -> bool; + void updateAnimations(float delta_time); void updateResizeAnimation(float delta_time); void updateShowHideAnimation(float delta_time); + void updateSwapAnimation(float delta_time); void updatePosition(); void precalculateMenuWidths(const std::vector>& all_options, const ServiceMenu* menu_state); // NOLINT(readability-avoid-const-params-in-decls) diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 3460b74..ef32168 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -347,6 +347,22 @@ void ServiceMenu::initializeOptions() { Options::gamepad_manager.swapPlayers(); adjustListValues(); // Sincroniza el valor de las opciones de lista (como MANDO1) con los datos reales updateOptionPairs(); // Actualiza los pares de texto que se van a dibujar + + // Feedback visual: anima el intercambio de los valores entre + // las filas de MANDO 1 y MANDO 2, imprescindible cuando los dos + // mandos tienen el mismo nombre (el texto no cambia al swap). + const std::string CAPTION1 = Lang::getText("[SERVICE_MENU] CONTROLLER1"); + const std::string CAPTION2 = Lang::getText("[SERVICE_MENU] CONTROLLER2"); + size_t idx1 = display_options_.size(); + size_t idx2 = display_options_.size(); + for (size_t i = 0; i < display_options_.size(); ++i) { + const auto& caption = display_options_[i]->getCaption(); + if (caption == CAPTION1) { idx1 = i; } + if (caption == CAPTION2) { idx2 = i; } + } + if (idx1 < display_options_.size() && idx2 < display_options_.size()) { + renderer_->startSwapAnimation(idx1, idx2); + } })); // VIDEO