1463 lines
56 KiB
C++
1463 lines
56 KiB
C++
#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/system/relaunch.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:
|
||
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) i ESC són hotkeys
|
||
// d'UI omnipresents (zoom, shader, screenshot, render info,
|
||
// menu_toggle, exit) i passen avall a GlobalInputs perquè es
|
||
// comporten igual amb el menú obert o tancat. La navegació enrere
|
||
// dins del menú queda exclusivament a BACKSPACE. 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;
|
||
const bool IS_PASSTHROUGH = IS_FUNCTION_KEY || SC == SDL_SCANCODE_ESCAPE;
|
||
return !IS_PASSTHROUGH;
|
||
}
|
||
|
||
// --- 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 {
|
||
// Hub amb una pàgina per jugador. Trenquem la pàgina única antiga
|
||
// perquè 12 keybinds + 2 labels feien la llista massa alta per al
|
||
// canvas. El títol de cada subpàgina (`menu.labels.player_N`) ja
|
||
// identifica el jugador, així que dins la subpàgina no posem la
|
||
// label "Jugador X" — seria redundant.
|
||
Page page;
|
||
page.title_key = "menu.titles.keyboard";
|
||
page.items.push_back(makeSubmenu("menu.labels.player_1",
|
||
[]() { ServiceMenu::get().pushPage(buildKeyboardControlsForPlayer(0)); }));
|
||
page.items.push_back(makeSubmenu("menu.labels.player_2",
|
||
[]() { ServiceMenu::get().pushPage(buildKeyboardControlsForPlayer(1)); }));
|
||
return page;
|
||
}
|
||
|
||
auto ServiceMenu::buildKeyboardControlsForPlayer(int player_id) -> Page {
|
||
Page page;
|
||
// Reutilitzem la clau `labels.player_N` també com a títol de la
|
||
// pàgina perquè ja conté el text correcte ("Jugador 1"/"Jugador 2");
|
||
// així evitem duplicar strings al locale.
|
||
page.title_key = (player_id == 0) ? "menu.labels.player_1" : "menu.labels.player_2";
|
||
|
||
using KA = Options::KeyboardAction;
|
||
const auto AS_U8 = [](KA k) { return static_cast<std::uint8_t>(k); };
|
||
|
||
// Ordre: moviment → atacar → saltar.
|
||
page.items.push_back(makeKeyboardItem("menu.items.move_up", player_id, AS_U8(KA::UP)));
|
||
page.items.push_back(makeKeyboardItem("menu.items.move_down", player_id, AS_U8(KA::DOWN)));
|
||
page.items.push_back(makeKeyboardItem("menu.items.move_left", player_id, AS_U8(KA::LEFT)));
|
||
page.items.push_back(makeKeyboardItem("menu.items.move_right", player_id, AS_U8(KA::RIGHT)));
|
||
page.items.push_back(makeKeyboardItem("menu.items.attack", player_id, AS_U8(KA::ATTACK)));
|
||
page.items.push_back(makeKeyboardItem("menu.items.jump", player_id, 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 + ")"));
|
||
|
||
// Reinici real del procés: marquem el flag i empenyem QUIT. SDL_AppQuit
|
||
// destruirà tots els subsistemes i llavors farà execv (al final, amb tot
|
||
// net) per substituir el binari per ell mateix. Si execv falla, el procés
|
||
// surt amb error.
|
||
page.items.push_back(makeAction(
|
||
"menu.items.restart",
|
||
[]() {
|
||
System::Relaunch::request();
|
||
SDL_Event quit_event{};
|
||
quit_event.type = SDL_EVENT_QUIT;
|
||
EventBuffer::events.push_back(quit_event);
|
||
}));
|
||
|
||
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();
|
||
}
|