943 lines
38 KiB
C++
943 lines
38 KiB
C++
// 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/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 {
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
} // 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.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;
|
||
}
|
||
|
||
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",
|
||
.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
|