feat(service-menu): pobla SISTEMA amb reinici, eixir i confirmacions
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user