feat(service-menu): pobla SISTEMA amb reinici, eixir i confirmacions

This commit is contained in:
2026-05-24 12:18:39 +02:00
parent 8c21345f14
commit 22827c28fa
8 changed files with 338 additions and 38 deletions
+192 -28
View File
@@ -4,6 +4,7 @@
#include "core/system/service_menu.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <format>
@@ -16,8 +17,10 @@
#include "core/locale/locale.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/debug_overlay.hpp"
#include "core/system/relaunch.hpp"
#include "core/types.hpp"
#include "game/config_yaml.hpp"
#include "project.h"
namespace {
@@ -50,6 +53,17 @@ namespace {
}
}
// VectorText nomes admet ASCII en majuscules. El git hash sortit de git
// rev-parse es lowercase (a-f), aixi que el passem a uppercase per al
// display sense modificar Project::GIT_HASH.
auto toUpperAscii(const std::string& s) -> std::string {
std::string result = s;
for (char& c : result) {
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
return result;
}
} // namespace
namespace System {
@@ -86,6 +100,10 @@ namespace System {
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;
}
@@ -121,20 +139,12 @@ namespace System {
makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }),
makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }),
makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }),
makeSubmenu("service_menu.system", [this] { pushSubmenuPlaceholder("service_menu.system"); }),
makeSubmenu("service_menu.system", [this] { pushPage(buildSystemPage()); }),
};
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 {
@@ -346,6 +356,76 @@ namespace System {
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, toUpperAscii(Project::GIT_HASH));
};
page.items = {
// REINICIAR (amb confirmacio).
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.system_restart",
.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",
.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<void()> 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",
.selectable = true,
.on_activate = [this] { popPage(); },
.get_value_text = {},
.on_change = {},
},
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.confirm_yes",
.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
@@ -463,8 +543,12 @@ namespace System {
}
using namespace Defaults::ServiceMenu;
const Page& page = stack_.back();
int h = GAP_Y; // padding superior
h += TITLE_HEIGHT + GAP_Y; // titol + gap
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<int>(page.items.size());
if (N > 0) {
@@ -473,15 +557,58 @@ namespace System {
return static_cast<float>(h);
}
auto ServiceMenu::computeItemTopY(float box_y, std::size_t index) -> float {
auto ServiceMenu::computeTargetWidth() const -> float {
using namespace Defaults::ServiceMenu;
const float ITEMS_Y0 = box_y +
static_cast<float>(GAP_Y) +
static_cast<float>(TITLE_HEIGHT) +
if (stack_.empty()) {
return static_cast<float>(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 = item.label_key.empty()
? std::string{}
: Locale::get().text(item.label_key);
if (item.label_key.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<float>(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<float>(HIGHLIGHT_PAD_X)) +
(2.0F * static_cast<float>(TEXT_INSET_X));
return std::max(static_cast<float>(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<float>(GAP_Y) +
static_cast<float>(TITLE_HEIGHT);
if (has_subtitle) {
items_y0 += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
}
items_y0 += static_cast<float>(GAP_Y) +
static_cast<float>(SEPARATOR_HEIGHT) +
static_cast<float>(GAP_Y);
return ITEMS_Y0 + (static_cast<float>(index) * static_cast<float>(ITEM_HEIGHT + ITEM_GAP_Y));
return items_y0 + (static_cast<float>(index) * static_cast<float>(ITEM_HEIGHT + ITEM_GAP_Y));
}
void ServiceMenu::update(float delta_time) {
@@ -504,10 +631,13 @@ namespace System {
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;
// 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
@@ -522,7 +652,8 @@ namespace System {
}
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 bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor, HAS_SUBTITLE);
const float TARGET_Y = ITEM_TOP - static_cast<float>(HIGHLIGHT_PAD_Y);
const float TARGET_H = static_cast<float>(ITEM_HEIGHT) + (2.0F * static_cast<float>(HIGHLIGHT_PAD_Y));
if (highlight_snap_) {
@@ -603,9 +734,9 @@ namespace System {
return;
}
const float BOX_X = (CANVAS_W - static_cast<float>(BOX_WIDTH)) * 0.5F;
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 auto BOX_W = static_cast<float>(BOX_WIDTH);
const float CENTER_X = BOX_X + (BOX_W * 0.5F);
// Fons semi-transparent.
@@ -623,6 +754,7 @@ namespace System {
renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H);
const Page& page = stack_.back();
const bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
// Titol centrat al cim de la caixa.
const std::string TITLE = Locale::get().text(page.title_key);
@@ -634,8 +766,27 @@ namespace System {
1.0F,
TITLE_COLOR);
// Separador horitzontal sota el titol.
const float SEP_Y = BOX_Y + static_cast<float>(GAP_Y) + static_cast<float>(TITLE_HEIGHT) + (static_cast<float>(GAP_Y) * 0.5F);
// Subtitol opcional: sota el titol, mes petit i apagat.
if (HAS_SUBTITLE) {
const float SUBTITLE_CY = BOX_Y + static_cast<float>(GAP_Y) +
static_cast<float>(TITLE_HEIGHT) +
(static_cast<float>(GAP_Y) / 4.0F) +
(static_cast<float>(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<float>(GAP_Y) + static_cast<float>(TITLE_HEIGHT);
if (HAS_SUBTITLE) {
sep_y += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
}
sep_y += static_cast<float>(GAP_Y) * 0.5F;
const float SEP_Y = sep_y;
fillRect(renderer_,
BOX_X + static_cast<float>(GAP_Y),
SEP_Y,
@@ -659,11 +810,24 @@ namespace System {
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);
// Salta el Locale lookup si label_key esta buit (item nomes-valor).
const std::string LABEL = item.label_key.empty()
? std::string{}
: Locale::get().text(item.label_key);
const float ITEM_TOP = computeItemTopY(BOX_Y, i, HAS_SUBTITLE);
const float ITEM_CY = ITEM_TOP + (static_cast<float>(ITEM_HEIGHT) * 0.5F);
if (item.get_value_text) {
if (item.label_key.empty() && item.get_value_text) {
// Item nomes-valor (sense label_key): 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;