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

1453 lines
56 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/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.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 "core/ui/ui_config.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp"
#include "game/options_apply.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;
// Mínim buit entre la label (a l'esquerra) i el value (a la dreta).
// Si amb el value sencer no queda aquest gap, truncam amb "..." des
// del final del value.
constexpr int MIN_LABEL_VALUE_GAP = 6;
// 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;
// Comparteix fonts/paleta amb el sub-mòdul de captura de botons —
// aquest pinta el seu propi overlay quan està actiu.
define_buttons_.init(font_, items_font_, 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.
}
}
void ServiceMenu::close() {
if (!open_) { return; }
open_ = false;
closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura.
capturing_ = 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) {
// El sub-mòdul de captura de botons té el seu propi cicle (timer post
// assignació). Una vegada `isReadyToClose`, el desactivem perquè el
// menú principal recuperi el control de l'input.
if (define_buttons_.isEnabled()) {
define_buttons_.update(delta_ms);
if (define_buttons_.isReadyToClose()) {
define_buttons_.disable();
}
}
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:
// Entra en mode captura: el Director redirigirà el pròxim
// KEY_DOWN cap a `captureKey()`. Només té sentit amb RETURN /
// ENTER / RIGHT (direction +1) — LEFT no fa res, com a la
// resta de Kinds que requereixen confirmació explícita.
if (direction > 0 && item.set_scancode) { capturing_ = true; }
break;
}
}
namespace {
// Resol el botó configurat per a una acció concreta (SERVICE / ATTACK
// / JUMP) al mando que ha emès aquest event. -1 si no troba cap match
// (mando desconegut o desconnectat mentrestant).
auto actionButtonForEvent(SDL_JoystickID jid, GamepadBindings::Action action) -> int {
for (const auto& info : GamepadManager::get().getAll()) {
if (info.pad == nullptr) { continue; }
if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; }
return GamepadBindings::get().getButton(info.name, info.path, action);
}
return -1;
}
} // namespace
void ServiceMenu::playSelectSound() {
Audio::get()->playSound(UiConfig::get().menuSelectSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::playAcceptSound() {
Audio::get()->playSound(UiConfig::get().menuAcceptSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev) {
// Llindars amb histèresi: cal travessar STICK_THRESHOLD per "entrar" a
// un extrem, però només es torna a la zona neutra quan baixa de
// STICK_RELEASE. Així evitem que petits tremolors al voltant del
// llindar facin spam d'events. Valors triats per donar la mateixa
// sensació que el stick del jugador (deadzone=12000) però una mica
// més estrictes per a la navegació discreta del menú.
constexpr Sint16 STICK_THRESHOLD = 16000;
constexpr Sint16 STICK_RELEASE = 8000;
std::int8_t* zone = nullptr;
bool horizontal = false;
switch (ev.axis) {
case SDL_GAMEPAD_AXIS_LEFTX:
zone = &lx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_LEFTY:
zone = &ly_zone_;
horizontal = false;
break;
case SDL_GAMEPAD_AXIS_RIGHTX:
zone = &rx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_RIGHTY:
zone = &ry_zone_;
horizontal = false;
break;
default:
return; // Ignorem L2/R2 i la resta.
}
// Càlcul de la zona nova: si el valor està més enllà del llindar fort,
// entra a l'extrem; si està dins de la zona morta, torna a 0; altrament
// manté la zona prèvia (banda d'histèresi).
std::int8_t new_zone = *zone;
if (ev.value > STICK_THRESHOLD) {
new_zone = +1;
} else if (ev.value < -STICK_THRESHOLD) {
new_zone = -1;
} else if (std::abs(ev.value) < STICK_RELEASE) {
new_zone = 0;
}
if (new_zone == *zone) { return; }
*zone = new_zone;
if (new_zone == 0) { return; } // Soltar el stick no dispara res.
if (horizontal) {
activateCurrent(new_zone);
playAcceptSound();
} else {
moveCursor(new_zone);
playSelectSound();
}
}
void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) {
// Tots tres botons rellevants (SERVICE/ATTACK/JUMP) són remapejables per
// dispositiu via `GamepadBindings`. Resolem-los una sola vegada a partir
// del joystick instance ID i comparem; els defaults (WEST=ATTACK,
// SOUTH=JUMP, BACK=SERVICE) preserven el comportament històric.
const int SERVICE_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::SERVICE);
const int ACCEPT_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::ATTACK);
const int CANCEL_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::JUMP);
const int BTN = static_cast<int>(ev.button);
if (SERVICE_BTN >= 0 && BTN == SERVICE_BTN) {
close();
playSelectSound();
return;
}
if (ACCEPT_BTN >= 0 && BTN == ACCEPT_BTN) {
activateCurrent(+1);
playAcceptSound();
return;
}
if (CANCEL_BTN >= 0 && BTN == CANCEL_BTN) {
popPage();
playSelectSound();
return;
}
switch (ev.button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP:
moveCursor(-1);
playSelectSound();
break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
moveCursor(+1);
playSelectSound();
break;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
activateCurrent(-1);
playAcceptSound();
break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
activateCurrent(+1);
playAcceptSound();
break;
default:
break;
}
}
void ServiceMenu::captureKey(SDL_Scancode sc) {
if (!capturing_) { return; }
// ESC cancel·la la captura sense assignar — replica AEE
// (`Menu::captureKey`). Qualsevol altra tecla queda assignada.
if (sc == SDL_SCANCODE_ESCAPE) {
capturing_ = false;
return;
}
if (stack_.empty()) {
capturing_ = false;
return;
}
const auto& page = topConst();
if (page.cursor >= page.items.size()) {
capturing_ = false;
return;
}
const auto& item = page.items[page.cursor];
if (item.kind == Item::Kind::KEY_BIND && item.set_scancode) {
item.set_scancode(sc);
}
capturing_ = false;
}
// --- Input ---
auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
if (!open_) { return false; }
// Mode de captura de botons del mando: redirigeix tots els events
// del gamepad (BUTTON_DOWN/UP, AXIS_MOTION) al sub-mòdul. ESCAPE de
// teclat cancel·la la sessió i tanca el sub-overlay sense persistir.
if (define_buttons_.isEnabled()) {
if (ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat &&
ev.key.scancode == SDL_SCANCODE_ESCAPE) {
define_buttons_.disable();
return true;
}
define_buttons_.handleEvent(ev);
return true;
}
// Mode de captura per a un KEY_BIND. Mentre dura, el menú consumeix
// TOTS els events: el primer KEY_DOWN s'assigna a l'item seleccionat
// (o ESC cancel·la), i la resta queden bloquejats perquè cap tecla
// s'escapi al joc. Replica el comportament d'AEE
// (`Menu::isCapturing` + `Menu::captureKey`).
if (capturing_) {
if (ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat) {
captureKey(ev.key.scancode);
}
return true;
}
// 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) {
handleGamepadButtonDown(ev.gbutton);
return true;
}
if (ev.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) {
handleGamepadAxisMotion(ev.gaxis);
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);
playSelectSound();
break;
case SDL_SCANCODE_DOWN:
moveCursor(+1);
playSelectSound();
break;
case SDL_SCANCODE_LEFT:
activateCurrent(-1);
playAcceptSound();
break;
case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
activateCurrent(+1);
playAcceptSound();
break;
case SDL_SCANCODE_BACKSPACE:
case SDL_SCANCODE_ESCAPE:
popPage();
playSelectSound();
break;
default:
break;
}
// Context d'entrada exclusiu: amb el menú obert, el teclat va al menú
// i les escenes no veuen res. Les F-keys (F1–F12) són hotkeys d'UI
// omnipresents (zoom, shader, screenshot, render info, menu_toggle…)
// i passen avall a GlobalInputs. Tota la resta queda absorbida —
// inclús tecles que el menú no fa servir — perquè una J en LogoScene
// no salti la secció mentre l'usuari està al menú.
const bool IS_FUNCTION_KEY = SC >= SDL_SCANCODE_F1 && SC <= SDL_SCANCODE_F12;
return !IS_FUNCTION_KEY;
}
// --- 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.tweaks_submenu",
[]() { ServiceMenu::get().pushPage(buildTweaks()); }));
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;
}
namespace {
auto getKeyboardScancode(int player_id, Options::KeyboardAction action) -> SDL_Scancode {
const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1
: Options::controls().keyboard_p2;
switch (action) {
case Options::KeyboardAction::UP:
return kb.up;
case Options::KeyboardAction::DOWN:
return kb.down;
case Options::KeyboardAction::LEFT:
return kb.left;
case Options::KeyboardAction::RIGHT:
return kb.right;
case Options::KeyboardAction::JUMP:
return kb.jump;
case Options::KeyboardAction::ATTACK:
return kb.attack;
}
return SDL_SCANCODE_UNKNOWN;
}
} // namespace
auto ServiceMenu::makeKeyboardItem(const std::string& label_key, int player_id, std::uint8_t action) -> Item {
const auto KA = static_cast<Options::KeyboardAction>(action);
return makeKeyBind(
label_key,
[player_id, KA]() { return getKeyboardScancode(player_id, KA); },
[player_id, KA](SDL_Scancode sc) { Options::setKeyboardBinding(player_id, KA, sc); });
}
auto ServiceMenu::buildKeyboardControls() -> Page {
Page page;
page.title_key = "menu.titles.keyboard";
using KA = Options::KeyboardAction;
const auto AS_U8 = [](KA k) { return static_cast<std::uint8_t>(k); };
// Bloc del jugador 1. Ordre: moviment → atacar → saltar.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1")));
page.items.push_back(makeKeyboardItem("menu.items.move_up", 0, AS_U8(KA::UP)));
page.items.push_back(makeKeyboardItem("menu.items.move_down", 0, AS_U8(KA::DOWN)));
page.items.push_back(makeKeyboardItem("menu.items.move_left", 0, AS_U8(KA::LEFT)));
page.items.push_back(makeKeyboardItem("menu.items.move_right", 0, AS_U8(KA::RIGHT)));
page.items.push_back(makeKeyboardItem("menu.items.attack", 0, AS_U8(KA::ATTACK)));
page.items.push_back(makeKeyboardItem("menu.items.jump", 0, AS_U8(KA::JUMP)));
// Bloc del jugador 2.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_2")));
page.items.push_back(makeKeyboardItem("menu.items.move_up", 1, AS_U8(KA::UP)));
page.items.push_back(makeKeyboardItem("menu.items.move_down", 1, AS_U8(KA::DOWN)));
page.items.push_back(makeKeyboardItem("menu.items.move_left", 1, AS_U8(KA::LEFT)));
page.items.push_back(makeKeyboardItem("menu.items.move_right", 1, AS_U8(KA::RIGHT)));
page.items.push_back(makeKeyboardItem("menu.items.attack", 1, AS_U8(KA::ATTACK)));
page.items.push_back(makeKeyboardItem("menu.items.jump", 1, AS_U8(KA::JUMP)));
return page;
}
namespace {
// Trunca `text` amb "..." perquè la seua amplada renderitzada no
// supere `max_w` píxels amb la font i el kerning donats. Port del
// helper de coffee_crisis_arcade_edition: estimació inicial a partir
// de l'amplada mitjana per caràcter i ajust iteratiu mesurant amb
// `Text::length`, per a fonts d'amplada variable on l'estimació
// només és aproximada.
auto truncateToFit(const Text& font, const std::string& text, int max_w, int kerning) -> std::string {
if (font.length(text, kerning) <= max_w) { return text; }
static constexpr const char* ELLIPSIS = "...";
const int ELLIPSIS_W = font.length(ELLIPSIS, kerning);
if (max_w <= ELLIPSIS_W || text.empty()) { return ELLIPSIS; }
const auto TOTAL_W = static_cast<float>(font.length(text, kerning));
const auto AVG_CHAR_W = TOTAL_W / static_cast<float>(text.size());
auto keep = static_cast<std::size_t>(static_cast<float>(max_w - ELLIPSIS_W) / AVG_CHAR_W);
keep = std::min(keep, text.size());
std::string out = text.substr(0, keep) + ELLIPSIS;
while (keep > 0 && font.length(out, kerning) > max_w) {
--keep;
out = text.substr(0, keep) + ELLIPSIS;
}
return out;
}
// Parell (fill, outline) per a un text del menú. Extret per a
// collir-los en un sol pas i no inflar la complexitat cognitiva
// de `renderPage` amb dos ternaris en paral·lel.
struct MenuColorPair {
Uint8 fill;
Uint8 outline;
};
auto pickValueColors(bool selected) -> MenuColorPair {
if (selected) { return {.fill = UiConfig::get().menuCursor(), .outline = UiConfig::get().menuCursorOutline()}; }
return {.fill = UiConfig::get().menuValue(), .outline = UiConfig::get().menuValueOutline()};
}
// Dibuixa el value d'un item a la dreta del box, truncant-lo amb
// "..." si no cap a l'esquerra de la label. Extret de `renderPage`
// per a mantenir la complexitat cognitiva sota el llindar de
// clang-tidy.
void drawItemValue(Text& font, int box_x, int box_w, int cursor_y, int cursor_width, const std::string& label, const std::string& value, int kerning, Uint8 color, Uint8 outline) {
const int LABEL_W = font.length(label, kerning);
const int AVAILABLE_W = box_w - (2 * PAD_X) - cursor_width - LABEL_W - MIN_LABEL_VALUE_GAP;
if (AVAILABLE_W <= 0) { return; }
const std::string DISPLAY = truncateToFit(font, value, AVAILABLE_W, kerning);
if (DISPLAY.empty()) { return; }
const int VALUE_W = font.length(DISPLAY, kerning);
font.writeDX(Text::COLOR_FLAG | Text::STROKE_FLAG,
box_x + box_w - PAD_X - VALUE_W,
cursor_y,
DISPLAY,
kerning,
color,
1,
outline);
}
// Mandos visibles al cycle d'un jugador: tots els connectats
// EXCEPTE els reclamats per l'altre jugador. Així l'usuari mai pot
// assignar el mateix mando a dos jugadors des de la UI — per
// reassignar-lo ha d'alliberar-lo primer posant-lo a "Cap" a l'altre
// slot. L'ordre de la llista manté el de `getAll()`.
auto visiblePads(int player_id) -> std::vector<GamepadManager::Info> {
const auto ALL = GamepadManager::get().getAll();
const auto& other = (player_id == 0) ? Options::controls().gamepad_p2
: Options::controls().gamepad_p1;
std::vector<GamepadManager::Info> out;
out.reserve(ALL.size());
for (const auto& p : ALL) {
const bool OWNED_BY_OTHER =
(!other.path.empty() && p.path == other.path) ||
(other.path.empty() && !other.name.empty() && p.name == other.name);
if (!OWNED_BY_OTHER) { out.push_back(p); }
}
return out;
}
// Índex del cycle dins de la llista `visiblePads(player_id)`.
// 0 = "Cap" (desassignat o sense coincidència amb cap mando visible);
// 1..N = mando `idx-1`. Primer match per ruta, fallback per nom.
auto currentPadIndex(int player_id) -> std::size_t {
const auto PADS = visiblePads(player_id);
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
if (slot.name.empty() && slot.path.empty()) { return 0; }
for (std::size_t i = 0; i < PADS.size(); ++i) {
if (!slot.path.empty() && PADS[i].path == slot.path) { return i + 1; }
}
for (std::size_t i = 0; i < PADS.size(); ++i) {
if (!slot.name.empty() && PADS[i].name == slot.name) { return i + 1; }
}
return 0;
}
auto padLabel(int player_id, std::size_t idx) -> std::string {
if (idx == 0) { return Locale::get().text("menu.values.gamepad_none"); }
const auto PADS = visiblePads(player_id);
const std::size_t POS = idx - 1;
if (POS < PADS.size()) { return PADS[POS].name; }
// Fora de rang: l'assignació guardada no és visible ara (p. ex.
// el mando s'acaba de desconnectar). Mostrem el nom persistit
// amb un sufix per no deixar l'usuari sense feedback.
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')';
}
void applyPadSelection(int player_id, std::size_t idx) {
if (idx == 0) {
Options::setGamepadAssignment(player_id, "", "");
return;
}
const auto PADS = visiblePads(player_id);
const std::size_t POS = idx - 1;
if (POS >= PADS.size()) { return; }
Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path);
}
} // namespace
namespace {
// Acció: arranca DefineButtons per al mando assignat al jugador.
// Si no hi ha mando, no fa res — l'item ja s'amaga via `visible`.
void startDefineButtonsForPlayer(int player_id) {
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
if (pad == nullptr) { return; }
const auto& slot = (player_id == 0)
? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
// Anem a buscar el nom/path actuals del mando físic, no els
// persistits — així podem captar correctament el mateix mando
// si l'usuari l'ha mogut de port USB i només en queda el nom.
std::string name = slot.name;
std::string path = slot.path;
const auto ALL = GamepadManager::get().getAll();
const auto IT = std::ranges::find_if(ALL,
[pad](const auto& info) { return info.pad == pad; });
if (IT != ALL.end()) {
name = IT->name;
path = IT->path;
}
ServiceMenu::get().startDefineButtons(pad, name, path);
}
auto playerHasGamepad(int player_id) -> bool {
return GamepadManager::get().getForPlayer(player_id) != nullptr;
}
} // namespace
auto ServiceMenu::buildGamepadControls() -> Page {
Page page;
page.title_key = "menu.titles.gamepad";
// Un cycle per jugador. L'opció 0 és sempre "Cap"; la resta es
// generen dinàmicament a partir de `GamepadManager::getAll()` cada
// frame, així que un mando acabat de connectar ja apareix sense
// haver de tornar a obrir el submenú.
page.items.push_back(makeCycle(
"menu.items.gamepad_p1",
[]() { return currentPadIndex(0); },
[]() { return visiblePads(0).size() + 1; },
[](std::size_t idx) { applyPadSelection(0, idx); },
[](std::size_t idx) { return padLabel(0, idx); }));
page.items.push_back(makeCycle(
"menu.items.gamepad_p2",
[]() { return currentPadIndex(1); },
[]() { return visiblePads(1).size() + 1; },
[](std::size_t idx) { applyPadSelection(1, idx); },
[](std::size_t idx) { return padLabel(1, idx); }));
// Accions per a llançar `DefineButtons` per a cada slot assignat.
// Només visibles si el jugador té un mando vinculat — equivalent al
// `define_buttons_->enable(...)` que dispara l'acció a coffee_crisis.
Item define_p1 = makeAction("menu.items.define_buttons_p1",
[]() { startDefineButtonsForPlayer(0); });
define_p1.visible = []() { return playerHasGamepad(0); };
page.items.push_back(std::move(define_p1));
Item define_p2 = makeAction("menu.items.define_buttons_p2",
[]() { startDefineButtonsForPlayer(1); });
define_p2.visible = []() { return playerHasGamepad(1); };
page.items.push_back(std::move(define_p2));
return page;
}
void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name, const std::string& path) {
// Wrapper públic perquè els items del menú (factories estàtiques)
// puguin atacar el sub-mòdul sense exposar-lo. Evita haver de fer
// `define_buttons_` `public`.
define_buttons_.enable(pad, name, path);
}
auto ServiceMenu::buildTweaks() -> Page {
// Submenú "Tweaks" — flags de jugabilitat persistits a `gameplay.yaml`
// dins la carpeta de sistema. Cada toggle muta `GameplayConfig` i
// n'invoca `save()` immediatament: l'usuari veu el canvi reflectit al
// joc sense esperar a un "aplicar" i, si tanca el joc, torna en el
// mateix estat. Tres toggles ON/OFF, sense estat addicional.
Page page;
page.title_key = "menu.titles.tweaks";
page.items.push_back(makeToggle(
"menu.items.whip_pierces_walls",
[]() { return GameplayConfig::get().whipDamagePiercesWalls(); },
[](bool v) {
GameplayConfig::get().setWhipDamagePiercesWalls(v);
GameplayConfig::get().save();
}));
page.items.push_back(makeToggle(
"menu.items.friendly_fire",
[]() { return GameplayConfig::get().friendlyFire(); },
[](bool v) {
GameplayConfig::get().setFriendlyFire(v);
GameplayConfig::get().save();
}));
page.items.push_back(makeToggle(
"menu.items.players_collide",
[]() { return GameplayConfig::get().playersCollide(); },
[](bool v) {
GameplayConfig::get().setPlayersCollide(v);
GameplayConfig::get().save();
}));
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: tornem al LOGO amb `reset=true` perquè LogoScene
// ature música i sons abans de mostrar-se. Tanquem el menú a la
// mateixa acció — Director recollirà la transició al final del frame
// via `takePendingSceneRequest()` i reconstruirà l'escena.
page.items.push_back(makeAction(
"menu.items.restart",
[]() {
ServiceMenu::get().requestSceneTransition(ExitRequest{
.next = SceneId::LOGO,
.params = LogoParams{.reset = true},
});
ServiceMenu::get().close();
}));
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_[UiConfig::get().menuBg()];
const std::uint32_t BORDER_ARGB = ui_palette_[UiConfig::get().menuBorder()];
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 | Text::STROKE_FLAG,
CENTER_X,
cursor_y,
Locale::get().text(page.title_key),
TITLE_KERNING,
UiConfig::get().menuTitle(),
1,
UiConfig::get().menuTitleOutline());
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
if (!page.subtitle.empty()) {
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE_FLAG,
CENTER_X,
cursor_y,
page.subtitle,
TITLE_KERNING,
UiConfig::get().menuTitle(),
1,
UiConfig::get().menuTitleOutline());
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_[UiConfig::get().menuBorder()]);
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 bool CAPTURING_THIS = capturing_ && SELECTED && (item.kind == Item::Kind::KEY_BIND);
const std::string LABEL_TEXT = (item.kind == Item::Kind::LABEL)
? item.label_key
: Locale::get().text(item.label_key);
const std::string VALUE = CAPTURING_THIS
? Locale::get().text("menu.values.press_key")
: formatValue(item);
Uint8 label_color = UiConfig::get().menuLabel();
Uint8 label_outline = UiConfig::get().menuLabelOutline();
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 — fill
// i outline van aparellats.
label_color = UiConfig::get().menuTitle();
label_outline = UiConfig::get().menuTitleOutline();
} else if (SELECTED) {
label_color = UiConfig::get().menuCursor();
label_outline = UiConfig::get().menuCursorOutline();
}
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 | Text::STROKE_FLAG,
label_x - CURSOR_WIDTH,
cursor_y,
"> ",
ITEM_KERNING,
UiConfig::get().menuCursor(),
1,
UiConfig::get().menuCursorOutline());
}
items_font_->writeDX(Text::COLOR_FLAG | Text::STROKE_FLAG,
label_x,
cursor_y,
LABEL_TEXT,
ITEM_KERNING,
label_color,
1,
label_outline);
// 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 auto VALUE_COLORS = pickValueColors(SELECTED);
drawItemValue(*items_font_, box_x, box_w, cursor_y, CURSOR_WIDTH, LABEL_TEXT, VALUE, ITEM_KERNING, VALUE_COLORS.fill, VALUE_COLORS.outline);
}
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();
// Overlay de captura de botons al damunt del menú. Es pinta sense
// clip perquè ocupa la seua pròpia caixa centrada.
define_buttons_.render();
}