Files
aee-arcade/source/game/ui/service_menu.cpp
T

991 lines
37 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.
#include "game/ui/service_menu.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <string>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/input_state.hpp"
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "game/options.hpp"
#include "game/options_apply.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
#include "version.h"
namespace {
// Geometria de la caixa (píxels de canvas lògic 320×240).
//
// Dos nivells de distància vertical:
// - `GAP_Y` és la distància estructural: marge superior al títol,
// títol↔separador, separador↔primer item i últim item↔marge inferior.
// - `ITEM_GAP_Y` és la distància entre items consecutius (més tight
// per a que la llista no quede massa airejada).
constexpr int BOX_WIDTH = 260;
constexpr int PAD_X = 10;
constexpr int GAP_Y = 6;
constexpr int ITEM_GAP_Y = 2;
// Les fonts display reserven ~2 px de descendent al fons de la seua
// caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests
// glifs, eixos 2 px queden buits i el gap visual sota el títol sembla
// més gran que el del damunt. Compensem-ho restant-los al càlcul
// d'alçada i al posicionament del separador; així el títol queda
// òpticament centrat entre la vora superior i la línia divisòria.
constexpr int TITLE_DESCENDER_TRIM = 2;
constexpr int MIN_ZOOM = 1;
constexpr int MAX_ZOOM = 6;
constexpr int VOLUME_STEP_PCT = 5; // Pas d'UI (5 %) per als volums.
// --- Animació ---
//
// `open_anim_` va de 0 (invisible) a 1 (totalment obert). La velocitat
// dicta quant triga a obrir-se o tancar-se. HEIGHT_RATE és el factor de
// suavitzat exponencial per a l'alçada quan canvia el nombre d'items
// visibles dins d'una mateixa pàgina.
constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125 s
constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1 s — més ràpid que obrir
constexpr float HEIGHT_RATE = 12.0F; // smoothing alçada (~150 ms @ 90 %)
constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms; reservat per al lliscament lateral de push/pop.
constexpr float MS_PER_SECOND = 1000.0F;
constexpr std::uint8_t BG_ALPHA = 220; // Opacitat màxima del fons.
// Les claus de locale viuen al .yaml, però el `menu_toggle` id el llegim
// ací a cada event per evitar cachejar un scancode que KeyConfig recàrrega.
constexpr const char* KEY_ID_MENU_TOGGLE = "menu_toggle";
auto boolLabel(bool value) -> std::string {
return Locale::get().text(value ? "menu.values.on" : "menu.values.off");
}
auto scalingLabel(Defaults::ScalingMode mode) -> std::string {
switch (mode) {
case Defaults::ScalingMode::DISABLED:
return Locale::get().text("menu.values.scaling_disabled");
case Defaults::ScalingMode::STRETCH:
return Locale::get().text("menu.values.scaling_stretch");
case Defaults::ScalingMode::LETTERBOX:
return Locale::get().text("menu.values.scaling_letterbox");
case Defaults::ScalingMode::OVERSCAN:
return Locale::get().text("menu.values.scaling_overscan");
case Defaults::ScalingMode::INTEGER:
return Locale::get().text("menu.values.scaling_integer");
}
return {};
}
auto renderInfoLabel(Defaults::RenderInfoPosition pos) -> std::string {
switch (pos) {
case Defaults::RenderInfoPosition::OFF:
return Locale::get().text("menu.values.render_info_off");
case Defaults::RenderInfoPosition::TOP:
return Locale::get().text("menu.values.render_info_top");
case Defaults::RenderInfoPosition::BOTTOM:
return Locale::get().text("menu.values.render_info_bottom");
}
return {};
}
// Troba l'índex del preset actiu dins del vector; si no es troba cau a 0
// perquè el menú mostri el primer element disponible (evita índexs fora
// de rang que petarien `presets[idx]`).
template <typename Preset>
auto indexOfPreset(const std::vector<Preset>& presets, const std::string& name) -> std::size_t {
const auto IT = std::ranges::find_if(presets,
[&](const Preset& p) { return p.name == name; });
return (IT == presets.end()) ? 0 : std::distance(presets.begin(), IT);
}
// Ease-out quadratic: 1 - (1 - t)². Accelera al principi i frena al final.
// S'aplica a open_anim_ perquè el menú aparegui amb sensació de "pes".
auto easeOutQuad(float t) -> float {
t = std::clamp(t, 0.0F, 1.0F);
const float INV = 1.0F - t;
return 1.0F - (INV * INV);
}
} // namespace
// --- Factories d'Items ---
auto ServiceMenu::makeToggle(
std::string label_key,
std::function<bool()> getter,
std::function<void(bool)> setter,
std::function<bool()> visible) -> Item {
Item item;
item.kind = Item::Kind::TOGGLE;
item.label_key = std::move(label_key);
item.get_bool = std::move(getter);
item.set_bool = std::move(setter);
item.visible = std::move(visible);
return item;
}
auto ServiceMenu::makeCycle(
std::string label_key,
std::function<std::size_t()> get_index,
std::function<std::size_t()> get_count,
std::function<void(std::size_t)> set_index,
std::function<std::string(std::size_t)> value_label,
std::function<bool()> visible) -> Item {
Item item;
item.kind = Item::Kind::CYCLE;
item.label_key = std::move(label_key);
item.get_index = std::move(get_index);
item.get_count = std::move(get_count);
item.set_index = std::move(set_index);
item.value_label = std::move(value_label);
item.visible = std::move(visible);
return item;
}
auto ServiceMenu::makeIntRange(
std::string label_key,
std::function<int()> get_int,
std::function<void(int)> set_int,
int min_value,
int max_value,
int step,
std::function<std::string(int)> int_display,
std::function<bool()> visible) -> Item {
Item item;
item.kind = Item::Kind::INT_RANGE;
item.label_key = std::move(label_key);
item.get_int = std::move(get_int);
item.set_int = std::move(set_int);
item.min_value = min_value;
item.max_value = max_value;
item.step = step;
item.int_display = std::move(int_display);
item.visible = std::move(visible);
return item;
}
auto ServiceMenu::makeSubmenu(std::string label_key, std::function<void()> push_sub) -> Item {
Item item;
item.kind = Item::Kind::SUBMENU;
item.label_key = std::move(label_key);
item.push_sub = std::move(push_sub);
return item;
}
auto ServiceMenu::makeAction(std::string label_key, std::function<void()> action) -> Item {
Item item;
item.kind = Item::Kind::ACTION;
item.label_key = std::move(label_key);
item.action = std::move(action);
return item;
}
auto ServiceMenu::makeLabel(std::string text, std::function<bool()> visible) -> Item {
Item item;
item.kind = Item::Kind::LABEL;
item.label_key = std::move(text); // Per a LABEL el camp és text literal.
item.visible = std::move(visible);
item.selectable = false;
return item;
}
auto ServiceMenu::makeKeyBind(std::string label_key,
std::function<SDL_Scancode()> getter,
std::function<void(SDL_Scancode)> setter) -> Item {
Item item;
item.kind = Item::Kind::KEY_BIND;
item.label_key = std::move(label_key);
item.get_scancode = std::move(getter);
item.set_scancode = std::move(setter);
// Si no hi ha setter, l'item queda purament informatiu: no pot capturar
// una tecla nova. Així la primera iteració de la pàgina Controls pot
// mostrar les vinculacions actuals sense tenir encara la infraestructura
// de mutació a `Options::`.
item.selectable = static_cast<bool>(item.set_scancode);
return item;
}
// --- Cicle de vida ---
auto ServiceMenu::get() -> ServiceMenu& {
static ServiceMenu instance_;
return instance_;
}
void ServiceMenu::init(std::shared_ptr<Text> title_font,
std::shared_ptr<Text> items_font,
const Palette& ui_palette) {
font_ = std::move(title_font);
items_font_ = std::move(items_font);
ui_palette_ = ui_palette;
}
void ServiceMenu::toggle() {
if (open_) {
close();
} else {
stack_.clear();
stack_.push_back(buildRoot());
open_ = true;
closing_ = false;
animated_h_ = 0.0F; // Comença des de 0 perquè la caixa crega.
// Bloqueja l'input de polling: els consumidors com `Player` veuran
// l'estat del teclat tot-a-zero mentre el menú sigui obert. Els
// events (KEY_DOWN, etc.) ja els consumeix `processMenuInput`,
// així que la navegació del menú no es veu afectada.
InputState::setBlocked(true);
}
}
void ServiceMenu::close() {
if (!open_) { return; }
open_ = false;
closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura.
// Allibera el polling de seguida — la closing animation és purament
// visual i no ha d'impedir que el jugador es mogui un cop ha tancat.
InputState::setBlocked(false);
}
// `isVisible` inclou l'animació de tancament: retorna true mentre open_anim_>0
// encara que open_ ja siga false. Així render() es crida fins que la caixa
// ha acabat d'encongir-se.
auto ServiceMenu::isVisible() const -> bool {
return open_ || closing_ || open_anim_ > 0.0F;
}
void ServiceMenu::update(int delta_ms) {
const float DT = static_cast<float>(delta_ms) / MS_PER_SECOND;
if (closing_) {
open_anim_ -= CLOSE_SPEED * DT;
if (open_anim_ <= 0.0F) {
open_anim_ = 0.0F;
closing_ = false;
stack_.clear(); // Allibera les pàgines ara que la caixa ja no es veu.
animated_h_ = 0.0F;
transition_active_ = false;
}
} else if (open_ && open_anim_ < 1.0F) {
open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * DT));
}
// Transició push/pop entre pàgines. Mentre està activa, el contingut es
// manté amagat i només es veu la caixa redimensionant-se fins a l'alçada
// de la pàgina nova; quan acaba, reapareix el nou contingut.
if (transition_active_) {
transition_progress_ += TRANSITION_SPEED * DT;
if (transition_progress_ >= 1.0F) {
transition_progress_ = 1.0F;
transition_active_ = false;
}
}
// Alçada suavitzada: quan un item canvia la seua visibilitat en calent
// (p.ex. shader off oculta shader_type i preset), la caixa reacciona amb
// una animació en comptes de pegar un salt brusc.
if (open_ && !stack_.empty()) {
const auto TARGET = static_cast<float>(targetBoxHeight(topConst()));
if (animated_h_ <= 0.0F) {
animated_h_ = TARGET;
} else {
const float DIFF = TARGET - animated_h_;
if (std::fabs(DIFF) < 0.5F) {
animated_h_ = TARGET;
} else {
const float T = std::min(1.0F, HEIGHT_RATE * DT);
animated_h_ += DIFF * T;
}
}
}
}
// --- Navegació de pila ---
void ServiceMenu::pushPage(Page page) {
// Cursor inicial: primer item visible I seleccionable; si la pàgina
// només té LABELs, queda a 0 (cap item rebrà focus i l'usuari veu els
// textos sense més).
page.cursor = 0;
for (std::size_t i = 0; i < page.items.size(); ++i) {
const auto& item = page.items[i];
if ((!item.visible || item.visible()) && item.selectable) {
page.cursor = i;
break;
}
}
stack_.push_back(std::move(page));
transition_active_ = true;
transition_progress_ = 0.0F;
}
void ServiceMenu::popPage() {
if (stack_.size() > 1) {
stack_.pop_back();
transition_active_ = true;
transition_progress_ = 0.0F;
} else {
close();
}
}
auto ServiceMenu::top() -> Page& {
return stack_.back();
}
auto ServiceMenu::topConst() const -> const Page& {
return stack_.back();
}
auto ServiceMenu::isItemVisible(const Item& item) -> bool {
return !item.visible || item.visible();
}
// Busca el pròxim item visible i seleccionable en la direcció indicada
// (+1 avall, -1 amunt), amb wrap-around als extrems. Items no seleccionables
// (p.ex. LABELs amb versió/hash) es salten perquè el cursor no hi cau.
void ServiceMenu::moveCursor(int direction) {
if (stack_.empty()) { return; }
auto& page = top();
if (page.items.empty()) { return; }
const auto N = page.items.size();
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 (isItemVisible(page.items[idx]) && page.items[idx].selectable) {
page.cursor = idx;
return;
}
}
}
void ServiceMenu::activateCycle(const Item& item, int direction) {
if (!item.get_index || !item.get_count || !item.set_index) { return; }
const auto COUNT = item.get_count();
if (COUNT == 0) { return; }
const auto CUR = item.get_index();
// `direction` és +1 / -1. Traduïm sempre a un offset positiu dins del
// rang del cicle per evitar barrejar int i size_t.
const std::size_t OFFSET = (direction > 0) ? 1 : (COUNT - 1);
item.set_index((CUR + OFFSET) % COUNT);
}
void ServiceMenu::activateIntRange(const Item& item, int direction) {
if (!item.get_int || !item.set_int) { return; }
const int CUR = item.get_int();
const int NEXT = std::clamp(CUR + (direction * item.step), item.min_value, item.max_value);
if (NEXT != CUR) { item.set_int(NEXT); }
}
// Aplica l'acció de l'item seleccionat. `direction` val +1 per a
// RIGHT/RETURN i -1 per a LEFT; els toggles usen +1 com a "invert".
void ServiceMenu::activateCurrent(int direction) {
if (stack_.empty()) { return; }
const auto& page = topConst();
if (page.items.empty()) { return; }
const auto& item = page.items[page.cursor];
if (!isItemVisible(item) || !item.selectable) { return; }
switch (item.kind) {
case Item::Kind::LABEL:
break; // No interacciona.
case Item::Kind::TOGGLE:
if (item.get_bool && item.set_bool) {
item.set_bool(!item.get_bool());
}
break;
case Item::Kind::CYCLE:
activateCycle(item, direction);
break;
case Item::Kind::INT_RANGE:
activateIntRange(item, direction);
break;
case Item::Kind::SUBMENU:
if (direction > 0 && item.push_sub) { item.push_sub(); }
break;
case Item::Kind::ACTION:
if (direction > 0 && item.action) { item.action(); }
break;
case Item::Kind::KEY_BIND:
// TODO: entrar en mode captura quan hi hagi `set_scancode`.
// De moment no fem res — els items sense setter ja no són
// seleccionables (veure `makeKeyBind`), per tant aquest case
// només es trepitja si algú en fa un amb setter en el futur.
break;
}
}
// --- Input ---
auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
if (!open_) { return false; }
// Botons del gamepad: cruceta per navegar, EAST/NORTH com a OK,
// WEST/SOUTH com a BACK de pàgina, BACK tanca el menú sencer. Quan
// el menú està obert consumim TOTS els BUTTON_DOWN perquè cap botó
// arribi als consumidors de gameplay. Paral·lel al consum total de
// KEY_DOWN més avall.
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
switch (ev.gbutton.button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP:
moveCursor(-1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
moveCursor(+1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
activateCurrent(-1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_NORTH:
activateCurrent(+1);
break;
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_SOUTH:
popPage();
break;
case SDL_GAMEPAD_BUTTON_BACK:
close();
break;
default:
break;
}
return true;
}
if (ev.type != SDL_EVENT_KEY_DOWN) { return false; }
const auto SC = ev.key.scancode;
if (SC == KeyConfig::get().scancode(KEY_ID_MENU_TOGGLE)) {
close();
return true;
}
switch (SC) {
case SDL_SCANCODE_UP:
moveCursor(-1);
break;
case SDL_SCANCODE_DOWN:
moveCursor(+1);
break;
case SDL_SCANCODE_LEFT:
activateCurrent(-1);
break;
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
activateCurrent(+1);
break;
case SDL_SCANCODE_BACKSPACE:
case SDL_SCANCODE_ESCAPE:
popPage();
break;
default:
break;
}
// Mentre el menú està obert consumim TOTS els KEY_DOWN perquè les
// hotkeys globals (F1–F8, F11, F12) no s'activen per sota.
return true;
}
// --- Builders de pàgines ---
auto ServiceMenu::buildRoot() -> Page {
Page page;
page.title_key = "menu.titles.root";
page.items.push_back(makeSubmenu("menu.items.video_submenu",
[]() { ServiceMenu::get().pushPage(buildVideo()); }));
page.items.push_back(makeSubmenu("menu.items.audio_submenu",
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
[]() { ServiceMenu::get().pushPage(buildControls()); }));
page.items.push_back(makeSubmenu("menu.items.system_submenu",
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
return page;
}
auto ServiceMenu::buildVideo() -> Page {
using Mode = Defaults::ScalingMode;
using Pos = Defaults::RenderInfoPosition;
Page page;
page.title_key = "menu.titles.video";
// Zoom: IntRange [1..6] amb display "x{N}". Amagat si fullscreen (com
// a les hotkeys: no té efecte en pantalla completa).
page.items.push_back(makeIntRange(
"menu.items.zoom",
[]() { return Options::window().zoom; },
[](int v) {
Options::setWindowZoom(v);
Screen::get().setWindowZoom(v);
},
MIN_ZOOM,
MAX_ZOOM,
1,
[](int v) { return "x" + std::to_string(v); },
[]() { return !Options::window().fullscreen; }));
page.items.push_back(makeToggle(
"menu.items.screen",
[]() { return Options::window().fullscreen; },
[](bool v) {
Options::setWindowFullscreen(v);
Screen::get().setFullscreen(v);
}));
page.items.push_back(makeToggle(
"menu.items.vsync",
[]() { return Options::video().vsync; },
[](bool v) {
Options::setVideoVSync(v);
Screen::get().setVSync(v);
}));
// Cicle dels 5 modes d'escalat.
page.items.push_back(makeCycle(
"menu.items.scaling_mode",
[]() { return static_cast<std::size_t>(Options::video().scaling_mode); },
[]() { return std::size_t{5}; },
[](std::size_t idx) {
const auto NEW_MODE = static_cast<Mode>(idx);
Options::setVideoScalingMode(NEW_MODE);
Screen::get().setScalingMode(NEW_MODE);
},
[](std::size_t idx) { return scalingLabel(static_cast<Mode>(idx)); }));
page.items.push_back(makeToggle(
"menu.items.shader",
[]() { return Options::video().shader.enabled; },
[](bool v) {
Options::setShaderEnabled(v);
OptionsApply::reapplyShader();
}));
// Tipus de shader i preset: només visibles si el shader està actiu
// (altrament no tenen efecte observable). Extrets a helpers per mantenir
// la complexitat cognitiva de `buildVideo` sota el llindar de clang-tidy.
page.items.push_back(makeShaderTypeItem());
page.items.push_back(makeShaderPresetItem());
page.items.push_back(makeCycle(
"menu.items.render_info",
[]() { return static_cast<std::size_t>(Options::video().render_info_position); },
[]() { return static_cast<std::size_t>(Defaults::RENDER_INFO_POSITION_COUNT); },
[](std::size_t idx) { Options::setVideoRenderInfoPosition(static_cast<Pos>(idx)); },
[](std::size_t idx) { return renderInfoLabel(static_cast<Pos>(idx)); }));
page.items.push_back(makeToggle(
"menu.items.uptime",
[]() { return Options::video().render_info_show_time; },
[](bool v) { Options::setVideoRenderInfoShowTime(v); },
[]() { return Options::video().render_info_position != Pos::OFF; }));
return page;
}
auto ServiceMenu::makeShaderTypeItem() -> Item {
return makeCycle(
"menu.items.shader_type",
[]() { return Options::video().shader.current_shader == "crtpi" ? std::size_t{1} : std::size_t{0}; },
[]() { return std::size_t{2}; },
[](std::size_t idx) {
Options::setActiveShaderName(idx == 1 ? "crtpi" : "postfx");
OptionsApply::reapplyShader();
},
[](std::size_t idx) { return idx == 1 ? std::string{"CRT-Pi"} : std::string{"PostFX"}; },
[]() { return Options::video().shader.enabled; });
}
auto ServiceMenu::makeShaderPresetItem() -> Item {
// Els getters i setters es ramifiquen per `current_shader`: el selector
// actiu viu a `postfxPresets()` o `crtpiPresets()` segons el tipus triat.
auto is_crtpi = []() { return Options::video().shader.current_shader == "crtpi"; };
return makeCycle(
"menu.items.preset",
[is_crtpi]() {
return is_crtpi()
? indexOfPreset(Options::crtpiPresets(), Options::video().shader.current_crtpi_preset)
: indexOfPreset(Options::postfxPresets(), Options::video().shader.current_postfx_preset);
},
[is_crtpi]() {
return is_crtpi() ? Options::crtpiPresets().size() : Options::postfxPresets().size();
},
[is_crtpi](std::size_t idx) {
if (is_crtpi()) {
const auto& presets = Options::crtpiPresets();
if (idx < presets.size()) { Options::setCrtPiPreset(presets[idx].name); }
} else {
const auto& presets = Options::postfxPresets();
if (idx < presets.size()) { Options::setPostFXPreset(presets[idx].name); }
}
OptionsApply::reapplyShader();
},
[is_crtpi](std::size_t idx) -> std::string {
if (is_crtpi()) {
const auto& presets = Options::crtpiPresets();
return idx < presets.size() ? presets[idx].name : std::string{};
}
const auto& presets = Options::postfxPresets();
return idx < presets.size() ? presets[idx].name : std::string{};
},
[]() { return Options::video().shader.enabled; });
}
auto ServiceMenu::buildAudio() -> Page {
Page page;
page.title_key = "menu.titles.audio";
auto volume_display = [](int pct) { return std::to_string(pct); };
page.items.push_back(makeToggle(
"menu.items.master_enable",
[]() { return Options::audio().enabled; },
[](bool v) {
Options::setAudioEnabled(v);
OptionsApply::reapplyAudio();
}));
page.items.push_back(makeIntRange(
"menu.items.master_volume",
[]() { return Audio::toPercent(Options::audio().volume); },
[](int pct) {
Options::setAudioVolume(Audio::fromPercent(pct));
OptionsApply::reapplyAudio();
},
0,
100,
VOLUME_STEP_PCT,
volume_display,
[]() { return Options::audio().enabled; }));
page.items.push_back(makeToggle(
"menu.items.music",
[]() { return Options::audio().music.enabled; },
[](bool v) {
Options::setAudioMusicEnabled(v);
OptionsApply::reapplyAudio();
},
[]() { return Options::audio().enabled; }));
page.items.push_back(makeIntRange(
"menu.items.music_volume",
[]() { return Audio::toPercent(Options::audio().music.volume); },
[](int pct) {
Options::setAudioMusicVolume(Audio::fromPercent(pct));
OptionsApply::reapplyAudio();
},
0,
100,
VOLUME_STEP_PCT,
volume_display,
[]() { return Options::audio().enabled && Options::audio().music.enabled; }));
page.items.push_back(makeToggle(
"menu.items.sounds",
[]() { return Options::audio().sound.enabled; },
[](bool v) {
Options::setAudioSoundEnabled(v);
OptionsApply::reapplyAudio();
},
[]() { return Options::audio().enabled; }));
page.items.push_back(makeIntRange(
"menu.items.sounds_volume",
[]() { return Audio::toPercent(Options::audio().sound.volume); },
[](int pct) {
Options::setAudioSoundVolume(Audio::fromPercent(pct));
OptionsApply::reapplyAudio();
},
0,
100,
VOLUME_STEP_PCT,
volume_display,
[]() { return Options::audio().enabled && Options::audio().sound.enabled; }));
return page;
}
auto ServiceMenu::buildControls() -> Page {
Page page;
page.title_key = "menu.titles.controls";
page.items.push_back(makeSubmenu("menu.items.keyboard_submenu",
[]() { ServiceMenu::get().pushPage(buildKeyboardControls()); }));
// Comandament: la pàgina existeix però encara no té infraestructura
// d'integració (no detectem gamepads ni mapegem botons). Entra-hi per
// veure un placeholder fins que la lògica arribi.
page.items.push_back(makeSubmenu("menu.items.gamepad_submenu",
[]() { ServiceMenu::get().pushPage(buildGamepadControls()); }));
return page;
}
auto ServiceMenu::buildKeyboardControls() -> Page {
Page page;
page.title_key = "menu.titles.keyboard";
// Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2,
// només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles
// per sota, sense tocar cap altra cosa.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1")));
// Vinculacions actuals: les llegim directament de `Defaults::KeysGame`.
// Encara no hi ha setter, per tant els items surten com a informatius
// (no seleccionables). La lògica de captura i persistència arribarà en
// una segona iteració amb `Options::keysGameP1()`.
page.items.push_back(makeKeyBind("menu.items.move_up",
[]() { return Defaults::KeysGame::UP; }));
page.items.push_back(makeKeyBind("menu.items.move_down",
[]() { return Defaults::KeysGame::DOWN; }));
page.items.push_back(makeKeyBind("menu.items.move_left",
[]() { return Defaults::KeysGame::LEFT; }));
page.items.push_back(makeKeyBind("menu.items.move_right",
[]() { return Defaults::KeysGame::RIGHT; }));
return page;
}
auto ServiceMenu::buildGamepadControls() -> Page {
Page page;
page.title_key = "menu.titles.gamepad";
page.center_items = true;
// Placeholder: un sol LABEL que indica que la pàgina encara no té
// contingut real. Quan s'afegeixi el suport de gamepad, aquesta
// pàgina es substituirà pel llistat real de botons mapejables.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable")));
return page;
}
auto ServiceMenu::buildSystem() -> Page {
Page page;
page.title_key = "menu.titles.system";
// Tots els items són botons/etiquetes; no n'hi ha cap amb columna de
// valor a la dreta, així que els centrem per tenir la jerarquia visual
// pròpia d'un menú d'accions.
page.center_items = true;
// Versió + hash de commit com a primer item no-seleccionable. Format
// "ver.X.Y.Z (hash)"; el hash el posa CMake a version.h.in.
page.items.push_back(makeLabel(
std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")"));
// Reiniciar el joc: placeholder sense efecte fins que Director exposi
// un pas de restart en calent (fora d'abast d'aquesta iteració).
page.items.push_back(makeAction(
"menu.items.restart",
[]() {
// TODO: connectar amb un hipotètic Director::requestRestart().
}));
page.items.push_back(makeAction(
"menu.items.exit_game",
[]() {
SDL_Event quit_event{};
quit_event.type = SDL_EVENT_QUIT;
EventBuffer::events.push_back(quit_event);
}));
return page;
}
// --- Render ---
auto ServiceMenu::formatValue(const Item& item) -> std::string {
switch (item.kind) {
case Item::Kind::LABEL:
return {}; // Es dibuixa només la label (pàgina centrada o no).
case Item::Kind::TOGGLE:
return item.get_bool ? boolLabel(item.get_bool()) : std::string{};
case Item::Kind::CYCLE:
return (item.value_label && item.get_index) ? item.value_label(item.get_index()) : std::string{};
case Item::Kind::INT_RANGE: {
if (!item.get_int) { return {}; }
const int V = item.get_int();
return item.int_display ? item.int_display(V) : std::to_string(V);
}
case Item::Kind::SUBMENU:
return Locale::get().text("menu.values.submenu_arrow");
case Item::Kind::ACTION:
return {};
case Item::Kind::KEY_BIND:
return item.get_scancode ? SDL_GetScancodeName(item.get_scancode()) : std::string{};
}
return {};
}
auto ServiceMenu::targetBoxHeight(const Page& page) const -> int {
if (!font_ || !items_font_) { return 0; }
const int TITLE_H = font_->getLineHeight();
const int ITEM_H = items_font_->getLineHeight();
const auto VISIBLE_ITEMS = static_cast<int>(std::ranges::count_if(page.items,
[](const Item& item) { return isItemVisible(item); }));
// Layout: GAP_Y enmig dels blocs estructurals (top, títol↔separador,
// separador↔primer item, últim item↔bottom). Entre items consecutius,
// ITEM_GAP_Y (més petit, perquè els items no queden massa airejats).
int h = GAP_Y; // marge superior
h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // títol + gap (compensant descendent)
if (!page.subtitle.empty()) {
h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // subtítol idem
}
h += 1 + GAP_Y; // separador d'1 px + gap estructural
if (VISIBLE_ITEMS > 0) {
h += VISIBLE_ITEMS * ITEM_H;
h += (VISIBLE_ITEMS - 1) * ITEM_GAP_Y;
h += GAP_Y; // marge inferior
}
return h;
}
void ServiceMenu::renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const {
const std::uint32_t BG_ARGB = ui_palette_[UiColor::MENU_BG];
const std::uint32_t BORDER_ARGB = ui_palette_[UiColor::MENU_BORDER];
const auto ALPHA = static_cast<std::uint8_t>(std::lround(BG_ALPHA * eased));
// Fons semi-transparent (el joc queda visible apagat per darrere).
Screen::get().blendRect(box_x, box_y, box_w, box_h, BG_ARGB, ALPHA);
// Vora opaca per damunt perquè la caixa quedi definida.
Screen::get().fillRect(box_x, box_y, box_w, 1, BORDER_ARGB);
Screen::get().fillRect(box_x, box_y + box_h - 1, box_w, 1, BORDER_ARGB);
Screen::get().fillRect(box_x, box_y, 1, box_h, BORDER_ARGB);
Screen::get().fillRect(box_x + box_w - 1, box_y, 1, box_h, BORDER_ARGB);
}
void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) const {
const int TITLE_KERNING = font_->getDefaultKerning();
const int TITLE_H = font_->getLineHeight();
const int ITEM_KERNING = items_font_->getDefaultKerning();
const int ITEM_H = items_font_->getLineHeight();
int cursor_y = box_y + GAP_Y; // marge superior
const int CENTER_X = box_x + (box_w / 2);
// Amplada reservada per al cursor "> ". La label no canvia de posició
// quan canvia la selecció — el cursor es pinta dins d'aquest carril,
// a l'esquerra, i la label sempre comença al mateix X.
const int CURSOR_WIDTH = items_font_->length("> ", ITEM_KERNING);
const int LABEL_X = box_x + PAD_X + CURSOR_WIDTH;
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
CENTER_X,
cursor_y,
Locale::get().text(page.title_key),
TITLE_KERNING,
UiColor::MENU_TITLE);
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
if (!page.subtitle.empty()) {
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
CENTER_X,
cursor_y,
page.subtitle,
TITLE_KERNING,
UiColor::MENU_TITLE);
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
}
Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiColor::MENU_BORDER]);
cursor_y += 1 + GAP_Y;
bool first_item = true;
for (std::size_t i = 0; i < page.items.size(); ++i) {
const auto& item = page.items[i];
if (!isItemVisible(item)) { continue; }
if (!first_item) { cursor_y += ITEM_GAP_Y; }
first_item = false;
const bool SELECTED = item.selectable && (i == page.cursor);
const std::string LABEL_TEXT = (item.kind == Item::Kind::LABEL)
? item.label_key
: Locale::get().text(item.label_key);
const std::string VALUE = formatValue(item);
Uint8 label_color = UiColor::MENU_LABEL;
if (item.kind == Item::Kind::LABEL) {
// Mateix to que el títol de la pàgina: les LABELs actuen com
// a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a
// Sistema) i han de tenir la mateixa presència visual.
label_color = UiColor::MENU_TITLE;
} else if (SELECTED) {
label_color = UiColor::MENU_CURSOR;
}
int label_x = LABEL_X;
if (page.center_items) {
// En pàgines centrades, la label cau al centre del box i el
// cursor es col·loca immediatament a la seua esquerra.
const int LABEL_W = items_font_->length(LABEL_TEXT, ITEM_KERNING);
label_x = CENTER_X - (LABEL_W / 2);
}
if (SELECTED) {
items_font_->writeDX(Text::COLOR_FLAG,
label_x - CURSOR_WIDTH,
cursor_y,
"> ",
ITEM_KERNING,
UiColor::MENU_CURSOR);
}
items_font_->writeDX(Text::COLOR_FLAG,
label_x,
cursor_y,
LABEL_TEXT,
ITEM_KERNING,
label_color);
// Pàgines centrades no dibuixen columna de valor (s'assumeix que
// només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines
// d'opcions amb layout esquerra-dreta.
if (!page.center_items && !VALUE.empty()) {
const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING);
items_font_->writeDX(Text::COLOR_FLAG,
box_x + box_w - PAD_X - VALUE_W,
cursor_y,
VALUE,
ITEM_KERNING,
SELECTED ? UiColor::MENU_CURSOR : UiColor::MENU_VALUE);
}
cursor_y += ITEM_H;
}
}
void ServiceMenu::render() {
if (!isVisible() || stack_.empty() || !font_) { return; }
const auto& page = topConst();
const float EASED = easeOutQuad(open_anim_);
// Alçada animada per suavitzar transicions entre pàgines i canvis de
// visibilitat d'items; l'eased la multiplica per simular el "créixer"
// vertical durant l'obertura.
const int FULL_H = static_cast<int>(animated_h_ > 0.0F ? animated_h_ : targetBoxHeight(page));
int box_h = std::max(2, static_cast<int>(static_cast<float>(FULL_H) * EASED));
const int BOX_X = (GameCanvas::WIDTH - BOX_WIDTH) / 2;
const int BOX_Y = (GameCanvas::HEIGHT - box_h) / 2;
renderBox(BOX_X, BOX_Y, BOX_WIDTH, box_h, EASED);
// Clip al rectangle interior de la caixa (dins de la vora, que és d'1
// px). Així el text que cau fora — durant el creixement inicial, el
// canvi d'alçada en amagar/mostrar items, o el push/pop entre pàgines
// — queda retallat en comptes d'anar a parar sobre l'escena.
Rendering::pushClip({
.x = BOX_X + 1,
.y = BOX_Y + 1,
.w = BOX_WIDTH - 2,
.h = std::max(0, box_h - 2),
});
renderPage(page, BOX_X, BOX_Y, BOX_WIDTH);
Rendering::popClip();
}