Files
orni-attack/source/core/system/service_menu.cpp
T
JailDesigner 10a54aef91 fix(ui): nom del mando en majuscules a la UI sense modificar el config
VectorText nomes admet ASCII en majuscules; els noms dels mandos (i el
git hash) passaven pel toUpperAscii local del service_menu, pero les
notificacions de hot-plug i el text del CYCLE de la pagina CONTROLS
es mostraven amb el case original. Mou el helper a un utils compartit i
l'aplica a tots els punts de display sense tocar gamepad_name al config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:33:01 +02:00

1072 lines
42 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// service_menu.cpp - Implementacio del menu de servei
// © 2026 JailDesigner
#include "core/system/service_menu.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <format>
#include <utility>
#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<float>(color.r) / 255.0F, static_cast<float>(color.g) / 255.0F, static_cast<float>(color.b) / 255.0F, static_cast<float>(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<std::shared_ptr<Input::Gamepad>>& 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> 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<void()> 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<float>(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<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",
.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<std::size_t>(direction + static_cast<int>(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<int>(page.items.size());
if (N > 0) {
h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y;
}
return static_cast<float>(h);
}
auto ServiceMenu::computeTargetWidth() const -> float {
using namespace Defaults::ServiceMenu;
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 = 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<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));
}
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<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_) {
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<float>(HIGHLIGHT_THICKNESS);
const auto L = static_cast<float>(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<float>(CORNER_THICKNESS);
const auto AH = static_cast<float>(CORNER_ARM_H);
const auto AV = static_cast<float>(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<int>(BOX_X + static_cast<float>(CORNER_THICKNESS));
const int CLIP_Y = static_cast<int>(BOX_Y + static_cast<float>(CORNER_THICKNESS));
const int CLIP_W = static_cast<int>(BOX_W - (2.0F * static_cast<float>(CORNER_THICKNESS)));
const int CLIP_H = std::max(0, static_cast<int>(BOX_H - (2.0F * static_cast<float>(CORNER_THICKNESS))));
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);
const float TITLE_CY = BOX_Y + static_cast<float>(GAP_Y) + (static_cast<float>(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<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,
BOX_W - (2.0F * static_cast<float>(GAP_Y)),
static_cast<float>(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<float>(HIGHLIGHT_PAD_X);
const float HL_W = BOX_W - (2.0F * static_cast<float>(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<float>(HIGHLIGHT_PAD_X);
const float HL_RIGHT = BOX_X + BOX_W - static_cast<float>(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<float>(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<float>(TEXT_INSET_X), .y = TEXT_TOP_Y},
ITEM_SCALE,
TEXT_SPACING,
1.0F,
COL);
text_.render(VALUE,
Vec2{.x = HL_RIGHT - static_cast<float>(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