feat(service-menu): esquelet amb F12, brackets sci-fi i highlight animat
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
// service_menu.cpp - Implementacio del menu de servei
|
||||
// © 2026 JailDesigner
|
||||
|
||||
#include "core/system/service_menu.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/defaults/service_menu.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/types.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Easing ease-out quadratic per a l'obertura/tancament. Identic a
|
||||
// aee_arcade service_menu.cpp:114-120.
|
||||
auto easeOutQuad(float t) -> float {
|
||||
t = std::clamp(t, 0.0F, 1.0F);
|
||||
const float INV = 1.0F - t;
|
||||
return 1.0F - (INV * INV);
|
||||
}
|
||||
|
||||
// Canvas logic del joc (constants compartides amb la resta del renderer).
|
||||
constexpr float CANVAS_W = 1280.0F;
|
||||
constexpr float CANVAS_H = 720.0F;
|
||||
|
||||
// Crida pushRect amb un SDL_Color (els components s'escalen a [0..1]).
|
||||
void fillRect(Rendering::Renderer* renderer, float x, float y, float w, float h, SDL_Color color) {
|
||||
renderer->pushRect(x, y, w, h, static_cast<float>(color.r) / 255.0F, static_cast<float>(color.g) / 255.0F, static_cast<float>(color.b) / 255.0F, static_cast<float>(color.a) / 255.0F);
|
||||
}
|
||||
|
||||
void playSelectSound() {
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->playSound(Defaults::ServiceMenu::SELECT_SOUND, Audio::Group::INTERFACE);
|
||||
}
|
||||
}
|
||||
|
||||
void playAcceptSound() {
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->playSound(Defaults::ServiceMenu::ACCEPT_SOUND, Audio::Group::INTERFACE);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace System {
|
||||
|
||||
std::unique_ptr<ServiceMenu> ServiceMenu::instance;
|
||||
|
||||
void ServiceMenu::init(Rendering::Renderer* renderer) {
|
||||
instance.reset(new ServiceMenu(renderer));
|
||||
}
|
||||
|
||||
void ServiceMenu::destroy() {
|
||||
instance.reset();
|
||||
}
|
||||
|
||||
auto ServiceMenu::get() -> ServiceMenu* {
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
ServiceMenu::ServiceMenu(Rendering::Renderer* renderer)
|
||||
: renderer_(renderer),
|
||||
text_(renderer) {}
|
||||
|
||||
auto ServiceMenu::isOpen() const -> bool {
|
||||
return open_;
|
||||
}
|
||||
|
||||
void ServiceMenu::toggle() {
|
||||
if (!open_) {
|
||||
open_ = true;
|
||||
closing_ = false;
|
||||
open_anim_ = 0.0F;
|
||||
animated_h_ = 0.0F;
|
||||
highlight_snap_ = true; // primera frame: enganxar el highlight al cursor
|
||||
buildRootPage();
|
||||
playAcceptSound();
|
||||
return;
|
||||
}
|
||||
// Ja obert: iniciem tancament. open_ es mante a true fins que l'animacio
|
||||
// arriba a 0, per a permetre que update() segueixi avançant open_anim_.
|
||||
closing_ = true;
|
||||
playAcceptSound();
|
||||
}
|
||||
|
||||
void ServiceMenu::buildRootPage() {
|
||||
Page root;
|
||||
root.title_key = "service_menu.title";
|
||||
root.items = {
|
||||
Item{.kind = Kind::SUBMENU,
|
||||
.label_key = "service_menu.video",
|
||||
.selectable = true,
|
||||
.on_activate = [this] { pushSubmenuPlaceholder("service_menu.video"); }},
|
||||
Item{.kind = Kind::SUBMENU,
|
||||
.label_key = "service_menu.audio",
|
||||
.selectable = true,
|
||||
.on_activate = [this] { pushSubmenuPlaceholder("service_menu.audio"); }},
|
||||
Item{.kind = Kind::SUBMENU,
|
||||
.label_key = "service_menu.options",
|
||||
.selectable = true,
|
||||
.on_activate = [this] { pushSubmenuPlaceholder("service_menu.options"); }},
|
||||
Item{.kind = Kind::SUBMENU,
|
||||
.label_key = "service_menu.system",
|
||||
.selectable = true,
|
||||
.on_activate = [this] { pushSubmenuPlaceholder("service_menu.system"); }},
|
||||
};
|
||||
stack_.clear();
|
||||
stack_.push_back(std::move(root));
|
||||
}
|
||||
|
||||
void ServiceMenu::pushSubmenuPlaceholder(const std::string& title_key) {
|
||||
Page page;
|
||||
page.title_key = title_key;
|
||||
// items buit: el submenu mostra nomes el titol (al·iteracions futures
|
||||
// s'omplen amb opcions reals de Vsync/Zoom/Locale/Restart/Exit/etc.).
|
||||
pushPage(std::move(page));
|
||||
}
|
||||
|
||||
void ServiceMenu::pushPage(Page page) {
|
||||
stack_.push_back(std::move(page));
|
||||
// El cursor salta a una pagina nova: enganxem el highlight per a
|
||||
// evitar que vagi lliscant des de la posicio anterior.
|
||||
highlight_snap_ = true;
|
||||
}
|
||||
|
||||
void ServiceMenu::popPage() {
|
||||
if (stack_.size() <= 1) {
|
||||
// Estem a la pagina arrel: BACKSPACE tanca el menu.
|
||||
closing_ = true;
|
||||
playAcceptSound();
|
||||
return;
|
||||
}
|
||||
stack_.pop_back();
|
||||
highlight_snap_ = true;
|
||||
playAcceptSound();
|
||||
}
|
||||
|
||||
void ServiceMenu::moveCursor(int direction) {
|
||||
if (stack_.empty()) {
|
||||
return;
|
||||
}
|
||||
Page& page = stack_.back();
|
||||
const std::size_t N = page.items.size();
|
||||
if (N == 0) {
|
||||
return;
|
||||
}
|
||||
// Cerca el seguent item seleccionable amb wrap-around.
|
||||
std::size_t idx = page.cursor;
|
||||
for (std::size_t step = 0; step < N; ++step) {
|
||||
idx = (idx + static_cast<std::size_t>(direction + static_cast<int>(N))) % N;
|
||||
if (page.items[idx].selectable) {
|
||||
if (idx != page.cursor) {
|
||||
page.cursor = idx;
|
||||
playSelectSound();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServiceMenu::activateCurrent() {
|
||||
if (stack_.empty()) {
|
||||
return;
|
||||
}
|
||||
const Page& page = stack_.back();
|
||||
if (page.cursor >= page.items.size()) {
|
||||
return;
|
||||
}
|
||||
const Item& item = page.items[page.cursor];
|
||||
if (!item.selectable) {
|
||||
return;
|
||||
}
|
||||
if (item.on_activate) {
|
||||
item.on_activate();
|
||||
// SUBMENU/ACTION reprodueixen accept; els toggles futurs ho
|
||||
// gestionaran als seus propis callbacks si volen un altre so.
|
||||
playAcceptSound();
|
||||
}
|
||||
}
|
||||
|
||||
auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool {
|
||||
if (!open_ || stack_.empty() || event.type != SDL_EVENT_KEY_DOWN) {
|
||||
return false;
|
||||
}
|
||||
switch (event.key.scancode) {
|
||||
case SDL_SCANCODE_UP:
|
||||
moveCursor(-1);
|
||||
return true;
|
||||
case SDL_SCANCODE_DOWN:
|
||||
moveCursor(+1);
|
||||
return true;
|
||||
case SDL_SCANCODE_RETURN:
|
||||
case SDL_SCANCODE_KP_ENTER:
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
activateCurrent();
|
||||
return true;
|
||||
case SDL_SCANCODE_BACKSPACE:
|
||||
case SDL_SCANCODE_LEFT:
|
||||
popPage();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
auto ServiceMenu::computeTargetHeight() const -> float {
|
||||
if (stack_.empty()) {
|
||||
return 0.0F;
|
||||
}
|
||||
using namespace Defaults::ServiceMenu;
|
||||
const Page& page = stack_.back();
|
||||
int h = GAP_Y; // padding superior
|
||||
h += TITLE_HEIGHT + GAP_Y; // titol + gap
|
||||
h += SEPARATOR_HEIGHT + GAP_Y; // separador + gap
|
||||
const auto N = static_cast<int>(page.items.size());
|
||||
if (N > 0) {
|
||||
h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y;
|
||||
}
|
||||
return static_cast<float>(h);
|
||||
}
|
||||
|
||||
auto ServiceMenu::computeItemTopY(float box_y, std::size_t index) -> float {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
const float ITEMS_Y0 = box_y +
|
||||
static_cast<float>(GAP_Y) +
|
||||
static_cast<float>(TITLE_HEIGHT) +
|
||||
static_cast<float>(GAP_Y) +
|
||||
static_cast<float>(SEPARATOR_HEIGHT) +
|
||||
static_cast<float>(GAP_Y);
|
||||
return ITEMS_Y0 + (static_cast<float>(index) * static_cast<float>(ITEM_HEIGHT + ITEM_GAP_Y));
|
||||
}
|
||||
|
||||
void ServiceMenu::update(float delta_time) {
|
||||
if (!open_) {
|
||||
return;
|
||||
}
|
||||
using namespace Defaults::ServiceMenu;
|
||||
|
||||
if (closing_) {
|
||||
open_anim_ -= CLOSE_SPEED * delta_time;
|
||||
if (open_anim_ <= 0.0F) {
|
||||
open_anim_ = 0.0F;
|
||||
animated_h_ = 0.0F;
|
||||
open_ = false;
|
||||
closing_ = false;
|
||||
stack_.clear();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * delta_time));
|
||||
}
|
||||
|
||||
// Smoothing exponencial cap a l'alçada objectiu de la pagina superior.
|
||||
const float TARGET = closing_ ? 0.0F : computeTargetHeight();
|
||||
const float ALPHA = 1.0F - std::exp(-HEIGHT_RATE * delta_time);
|
||||
animated_h_ += (TARGET - animated_h_) * ALPHA;
|
||||
|
||||
// Highlight: lerp cap a la Y de l'item del cursor. Snapping nomes en
|
||||
// obrir o canviar de pagina; en moure el cursor amb UP/DOWN, el rect
|
||||
// llisca suaument cap a la nova posicio.
|
||||
if (stack_.empty()) {
|
||||
return;
|
||||
}
|
||||
const Page& page = stack_.back();
|
||||
if (page.items.empty()) {
|
||||
highlight_snap_ = true;
|
||||
return;
|
||||
}
|
||||
const float BOX_H_TARGET = computeTargetHeight();
|
||||
const float BOX_Y_TARGET = (CANVAS_H - BOX_H_TARGET) * 0.5F;
|
||||
const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor);
|
||||
const float TARGET_Y = ITEM_TOP - static_cast<float>(HIGHLIGHT_PAD_Y);
|
||||
const float TARGET_H = static_cast<float>(ITEM_HEIGHT) + (2.0F * static_cast<float>(HIGHLIGHT_PAD_Y));
|
||||
if (highlight_snap_) {
|
||||
highlight_y_ = TARGET_Y;
|
||||
highlight_h_ = TARGET_H;
|
||||
highlight_snap_ = false;
|
||||
} else {
|
||||
const float HL_ALPHA = 1.0F - std::exp(-HIGHLIGHT_RATE * delta_time);
|
||||
highlight_y_ += (TARGET_Y - highlight_y_) * HL_ALPHA;
|
||||
highlight_h_ += (TARGET_H - highlight_h_) * HL_ALPHA;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Dibuixa un rect (BG sombrejat + 4 ticks L als cantons), simulant
|
||||
// un visor sci-fi al voltant de l'item sel·leccionat.
|
||||
void drawHighlightRect(Rendering::Renderer* renderer, float x, float y, float w, float h) {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
if (w <= 0.0F || h <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
// Wash de fons translucid.
|
||||
fillRect(renderer, x, y, w, h, HIGHLIGHT_FILL);
|
||||
|
||||
const auto T = static_cast<float>(HIGHLIGHT_THICKNESS);
|
||||
const auto L = static_cast<float>(HIGHLIGHT_TICK_LEN);
|
||||
|
||||
// Top-left
|
||||
fillRect(renderer, x, y, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x, y, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Top-right
|
||||
fillRect(renderer, x + w - L, y, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x + w - T, y, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Bottom-left
|
||||
fillRect(renderer, x, y + h - T, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x, y + h - L, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Bottom-right
|
||||
fillRect(renderer, x + w - L, y + h - T, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x + w - T, y + h - L, T, L, HIGHLIGHT_OUTLINE);
|
||||
}
|
||||
|
||||
// Brackets als 4 cantons de la caixa (sci-fi HUD). Substitueix la vora
|
||||
// completa per un marc obert.
|
||||
void drawCornerBrackets(Rendering::Renderer* renderer, float x, float y, float w, float h) {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
const auto T = static_cast<float>(CORNER_THICKNESS);
|
||||
const auto AH = static_cast<float>(CORNER_ARM_H);
|
||||
const auto AV = static_cast<float>(CORNER_ARM_V);
|
||||
|
||||
// Top-left
|
||||
fillRect(renderer, x, y, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x, y, T, AV, CORNER_COLOR);
|
||||
// Top-right
|
||||
fillRect(renderer, x + w - AH, y, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x + w - T, y, T, AV, CORNER_COLOR);
|
||||
// Bottom-left
|
||||
fillRect(renderer, x, y + h - T, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x, y + h - AV, T, AV, CORNER_COLOR);
|
||||
// Bottom-right
|
||||
fillRect(renderer, x + w - AH, y + h - T, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x + w - T, y + h - AV, T, AV, CORNER_COLOR);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ServiceMenu::draw() const {
|
||||
if (!open_ || stack_.empty() || renderer_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
using namespace Defaults::ServiceMenu;
|
||||
|
||||
// Alçada final: smoothing × easing. easeOutQuad afegeix la sensacio
|
||||
// de "snap" al final de l'obertura i l'inici del tancament.
|
||||
const float EASED = easeOutQuad(open_anim_);
|
||||
const float BOX_H = animated_h_ * EASED;
|
||||
if (BOX_H < 1.0F) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float BOX_X = (CANVAS_W - static_cast<float>(BOX_WIDTH)) * 0.5F;
|
||||
const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F;
|
||||
const auto BOX_W = static_cast<float>(BOX_WIDTH);
|
||||
const float CENTER_X = BOX_X + (BOX_W * 0.5F);
|
||||
|
||||
// Fons semi-transparent.
|
||||
fillRect(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H, BG_COLOR);
|
||||
|
||||
// Brackets als cantons (substitueixen la vora completa).
|
||||
drawCornerBrackets(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H);
|
||||
|
||||
// Clip interior per a tallar text que sortiria del cuadre durant
|
||||
// l'animacio open/close. Marge generos perquè no es mengi els brackets.
|
||||
const int CLIP_X = static_cast<int>(BOX_X + static_cast<float>(CORNER_THICKNESS));
|
||||
const int CLIP_Y = static_cast<int>(BOX_Y + static_cast<float>(CORNER_THICKNESS));
|
||||
const int CLIP_W = static_cast<int>(BOX_W - (2.0F * static_cast<float>(CORNER_THICKNESS)));
|
||||
const int CLIP_H = std::max(0, static_cast<int>(BOX_H - (2.0F * static_cast<float>(CORNER_THICKNESS))));
|
||||
renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H);
|
||||
|
||||
const Page& page = stack_.back();
|
||||
|
||||
// Titol centrat al cim de la caixa.
|
||||
const std::string TITLE = Locale::get().text(page.title_key);
|
||||
const float TITLE_CY = BOX_Y + static_cast<float>(GAP_Y) + (static_cast<float>(TITLE_HEIGHT) * 0.5F);
|
||||
text_.renderCentered(TITLE,
|
||||
Vec2{.x = CENTER_X, .y = TITLE_CY},
|
||||
TITLE_SCALE,
|
||||
TEXT_SPACING,
|
||||
1.0F,
|
||||
TITLE_COLOR);
|
||||
|
||||
// Separador horitzontal sota el titol.
|
||||
const float SEP_Y = BOX_Y + static_cast<float>(GAP_Y) + static_cast<float>(TITLE_HEIGHT) + (static_cast<float>(GAP_Y) * 0.5F);
|
||||
fillRect(renderer_,
|
||||
BOX_X + static_cast<float>(GAP_Y),
|
||||
SEP_Y,
|
||||
BOX_W - (2.0F * static_cast<float>(GAP_Y)),
|
||||
static_cast<float>(SEPARATOR_HEIGHT),
|
||||
SEPARATOR_COLOR);
|
||||
|
||||
// Highlight rect: nomes si la pagina te items i el rect te alçada.
|
||||
if (!page.items.empty() && highlight_h_ > 0.0F) {
|
||||
const float HL_X = BOX_X + static_cast<float>(HIGHLIGHT_PAD_X);
|
||||
const float HL_W = BOX_W - (2.0F * static_cast<float>(HIGHLIGHT_PAD_X));
|
||||
drawHighlightRect(renderer_, HL_X, highlight_y_, HL_W, highlight_h_);
|
||||
}
|
||||
|
||||
// Llista d'items: centrats horitzontalment, color groc per al seleccionat.
|
||||
for (std::size_t i = 0; i < page.items.size(); ++i) {
|
||||
const Item& item = page.items[i];
|
||||
const SDL_Color COL = (i == page.cursor) ? CURSOR_COLOR : LABEL_COLOR;
|
||||
const std::string TEXT = Locale::get().text(item.label_key);
|
||||
const float ITEM_TOP = computeItemTopY(BOX_Y, i);
|
||||
const float ITEM_CY = ITEM_TOP + (static_cast<float>(ITEM_HEIGHT) * 0.5F);
|
||||
text_.renderCentered(TEXT,
|
||||
Vec2{.x = CENTER_X, .y = ITEM_CY},
|
||||
ITEM_SCALE,
|
||||
TEXT_SPACING,
|
||||
1.0F,
|
||||
COL);
|
||||
}
|
||||
|
||||
renderer_->popClip();
|
||||
}
|
||||
|
||||
} // namespace System
|
||||
Reference in New Issue
Block a user