Files
orni-attack/source/core/system/service_menu.cpp
T
JailDesigner 99d0f62ab5 feat(service_menu): slot 'sense mando' al cycle i swap automatic en conflicte
El CYCLE de la pagina CONTROLS ara inclou un slot virtual al final que
desassigna el mando (gamepad_name + gamepad_path buits → padDisplayName
mostra "SENSE MANDO"). Aixi l'usuari pot recuperar el control teclat
sense haver d'editar el YAML.

A mes, si en assignar un mando l'altre jugador ja el tenia, fem swap
automatic: l'altre jugador rep l'assignacio previa d'aquest, evitant
que dos jugadors comparteixen el mateix dispositiu. La deteccio
prioritza path (mateixa branca que resolvePlayerGamepad).

Extracta tambe reapplyBindings per mantenir cyclePlayerPad llegible.

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

1215 lines
48 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 del pad assignat dins de la llista. Retorna pads.size() per
// representar el slot virtual SENSE MANDO (al final del cycle).
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 pads.size(); // Slot virtual "sense mando"
}
// Aplica les noves assignacions a Input. Si ha hagut swap, refresca els
// dos jugadors; en cas contrari nomes el que ha canviat.
void reapplyBindings(int player_index, bool swap_other) {
auto* input = Input::get();
if (input == nullptr) {
return;
}
if (player_index == 0) {
input->applyPlayer1Bindings(ConfigYaml::engine_config.player1);
if (swap_other) {
input->applyPlayer2Bindings(ConfigYaml::engine_config.player2);
}
} else {
input->applyPlayer2Bindings(ConfigYaml::engine_config.player2);
if (swap_other) {
input->applyPlayer1Bindings(ConfigYaml::engine_config.player1);
}
}
}
// Avança ciclicament l'assignacio de pad d'un jugador i la persisteix.
// El cycle inclou un slot virtual "sense mando" al final (NEXT == N).
// Si l'altre jugador ja tenia el pad triat, fa swap: l'altre rep la
// assignacio prèvia d'aquest jugador.
void cyclePlayerPad(int player_index, int dir) {
const 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;
auto& other = (player_index == 0)
? ConfigYaml::engine_config.player2
: ConfigYaml::engine_config.player1;
const std::size_t N = pads.size();
const std::size_t SLOTS = N + 1; // N pads + slot "sense mando"
const std::size_t CURRENT = findAssignedIndex(pads, pcfg);
const std::size_t STEP = (dir > 0) ? 1 : (SLOTS - 1);
const std::size_t NEXT = (CURRENT + STEP) % SLOTS;
// Determinem el nou nom + path (buits si seleccionem "sense mando").
std::string new_name;
std::string new_path;
if (NEXT < N && pads[NEXT]) {
new_name = pads[NEXT]->name;
new_path = pads[NEXT]->path;
}
// Detecta conflicte amb l'altre jugador per fer swap.
const bool CONFLICT = !new_path.empty() && other.gamepad_path == new_path;
const bool CONFLICT_BY_NAME = !new_name.empty() && new_path.empty() &&
other.gamepad_name == new_name;
const bool DO_SWAP = CONFLICT || CONFLICT_BY_NAME;
const std::string PREV_NAME = pcfg.gamepad_name;
const std::string PREV_PATH = pcfg.gamepad_path;
pcfg.gamepad_name = new_name;
pcfg.gamepad_path = new_path;
if (DO_SWAP) {
other.gamepad_name = PREV_NAME;
other.gamepad_path = PREV_PATH;
}
reapplyBindings(player_index, DO_SWAP);
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::Item {
return ServiceMenu::Item{
.kind = ServiceMenu::Kind::ACTION,
.label_key = label_key,
.label_text = {},
.selectable = true,
// El menu de servei NO es tanca: queda obert per sota de
// l'overlay i absorbira qualsevol event que arribi un cop
// l'overlay s'haja auto-cancel·lat.
.on_activate = [mode, pl] { startDefine(mode, pl); },
.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),
makeDefineItem("service_menu.controls_define_keyboard_p2",
DefineInputs::Mode::KEYBOARD,
DefineInputs::Player::P2),
makeDefineItem("service_menu.controls_define_gamepad_p1",
DefineInputs::Mode::GAMEPAD,
DefineInputs::Player::P1),
makeDefineItem("service_menu.controls_define_gamepad_p2",
DefineInputs::Mode::GAMEPAD,
DefineInputs::Player::P2),
};
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;
}
}
namespace {
// Llindar de stick per a navegacio de menu (mig camp del rang ±32767).
// Mes baix que el del joc (30000) per a una resposta mes agil al menu.
constexpr Sint16 MENU_STICK_THRESHOLD = 16384;
// Retorna true si el codi de boto SDL coincideix amb l'accio
// configurada per algun dels dos jugadors (es a dir, el boto te el
// mateix codi al binding de FIRE o ACCELERATE del pad emissor).
auto buttonMatchesAction(SDL_JoystickID which, int button, InputAction action) -> bool {
const auto* input = Input::get();
if (input == nullptr) {
return false;
}
for (int i = 0; i < 2; ++i) {
auto pad = input->getPlayerGamepad(i);
if (!pad || pad->instance_id != which) {
continue;
}
auto it = pad->bindings.find(action);
if (it != pad->bindings.end() && it->second.button == button) {
return true;
}
}
return false;
}
} // namespace
auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool {
if (!open_ || stack_.empty()) {
return false;
}
if (event.type == SDL_EVENT_KEY_DOWN) {
return handleKeyDown(event);
}
if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
return handleGamepadButton(event);
}
if (event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) {
return handleGamepadAxis(event);
}
return false;
}
auto ServiceMenu::handleKeyDown(const SDL_Event& event) -> bool {
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::handleGamepadButton(const SDL_Event& event) -> bool {
const int BTN = static_cast<int>(event.gbutton.button);
if (BTN == SDL_GAMEPAD_BUTTON_DPAD_UP) {
moveCursor(-1);
return true;
}
if (BTN == SDL_GAMEPAD_BUTTON_DPAD_DOWN) {
moveCursor(+1);
return true;
}
if (BTN == SDL_GAMEPAD_BUTTON_DPAD_LEFT) {
changeValue(-1);
return true;
}
if (BTN == SDL_GAMEPAD_BUTTON_DPAD_RIGHT) {
changeValue(+1);
return true;
}
// Botons d'accio per al pad emissor: FIRE = ENTER, ACCELERATE = BACK.
if (buttonMatchesAction(event.gbutton.which, BTN, InputAction::SHOOT)) {
activateCurrent();
return true;
}
if (buttonMatchesAction(event.gbutton.which, BTN, InputAction::THRUST)) {
popPage();
return true;
}
return false;
}
auto ServiceMenu::handleGamepadAxis(const SDL_Event& event) -> bool {
const auto AXIS = static_cast<SDL_GamepadAxis>(event.gaxis.axis);
const Sint16 VAL = event.gaxis.value;
if (AXIS == SDL_GAMEPAD_AXIS_LEFTX) {
const bool LEFT_NOW = VAL < -MENU_STICK_THRESHOLD;
const bool RIGHT_NOW = VAL > MENU_STICK_THRESHOLD;
if (LEFT_NOW && !stick_left_held_) {
changeValue(-1);
}
if (RIGHT_NOW && !stick_right_held_) {
changeValue(+1);
}
stick_left_held_ = LEFT_NOW;
stick_right_held_ = RIGHT_NOW;
return true;
}
if (AXIS == SDL_GAMEPAD_AXIS_LEFTY) {
const bool UP_NOW = VAL < -MENU_STICK_THRESHOLD;
const bool DOWN_NOW = VAL > MENU_STICK_THRESHOLD;
if (UP_NOW && !stick_up_held_) {
moveCursor(-1);
}
if (DOWN_NOW && !stick_down_held_) {
moveCursor(+1);
}
stick_up_held_ = UP_NOW;
stick_down_held_ = DOWN_NOW;
return true;
}
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