feat(service-menu): esquelet amb F12, brackets sci-fi i highlight animat

This commit is contained in:
2026-05-24 11:25:09 +02:00
parent cc16908b86
commit 443b461974
12 changed files with 722 additions and 1 deletions
+2
View File
@@ -50,6 +50,8 @@ service_menu:
title: "MENU DE SERVEI"
video: "VIDEO"
audio: "AUDIO"
options: "OPCIONS"
system: "SISTEMA"
controls: "CONTROLS"
back: "ENRERE"
exit: "EIXIR DEL JOC"
+2
View File
@@ -49,6 +49,8 @@ service_menu:
title: "SERVICE MENU"
video: "VIDEO"
audio: "AUDIO"
options: "OPTIONS"
system: "SYSTEM"
controls: "CONTROLS"
back: "BACK"
exit: "EXIT GAME"
Binary file not shown.
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
// service_menu.hpp - Constants del menu de servei (F12)
// © 2026 JailDesigner
#pragma once
#include <SDL3/SDL.h>
namespace Defaults::ServiceMenu {
// ---- Mides en coordenades logiques del joc (1280×720) ----
constexpr int BOX_WIDTH = 460;
constexpr int GAP_Y = 22;
constexpr int TITLE_HEIGHT = 36; // scale 0.85 ≈ 34 px de text
constexpr int SEPARATOR_HEIGHT = 1;
constexpr int ITEM_HEIGHT = 38; // scale 0.55 ≈ 22 px de text + padding per al highlight
constexpr int ITEM_GAP_Y = 6;
// Brackets als 4 cantons (substitueixen la vora completa: estètica sci-fi).
constexpr int CORNER_ARM_H = 48;
constexpr int CORNER_ARM_V = 28;
constexpr int CORNER_THICKNESS = 2;
// ---- Animacio open/close (mateixos parametres que aee_arcade) ----
constexpr float OPEN_SPEED = 8.0F; // ~125 ms a obrir
constexpr float CLOSE_SPEED = 10.0F; // ~100 ms a tancar
constexpr float HEIGHT_RATE = 12.0F; // smoothing exponencial de l'alçada de la caixa
// ---- Animacio del highlight (rectangle del cursor) ----
// Rate=18 dona settling ~0.17 s al 95% (ease-out exponencial).
constexpr float HIGHLIGHT_RATE = 18.0F;
constexpr int HIGHLIGHT_TICK_LEN = 10; // longitud dels ticks a cada cantonada
constexpr int HIGHLIGHT_THICKNESS = 1;
constexpr int HIGHLIGHT_PAD_X = 18; // padding lateral del rect respecte al text
constexpr int HIGHLIGHT_PAD_Y = 4; // padding vertical
// ---- Colors RGBA ----
constexpr SDL_Color BG_COLOR{.r = 0, .g = 12, .b = 24, .a = 215};
constexpr SDL_Color CORNER_COLOR{.r = 120, .g = 220, .b = 255, .a = 255}; // cian neon
constexpr SDL_Color TITLE_COLOR{.r = 200, .g = 240, .b = 255, .a = 255};
constexpr SDL_Color SEPARATOR_COLOR{.r = 60, .g = 120, .b = 180, .a = 180};
constexpr SDL_Color LABEL_COLOR{.r = 170, .g = 210, .b = 240, .a = 255};
constexpr SDL_Color CURSOR_COLOR{.r = 255, .g = 230, .b = 120, .a = 255}; // groc per al text sel·leccionat
constexpr SDL_Color HIGHLIGHT_OUTLINE{.r = 255, .g = 230, .b = 120, .a = 255}; // mateix groc, opac
constexpr SDL_Color HIGHLIGHT_FILL{.r = 255, .g = 230, .b = 120, .a = 36}; // wash translucid
// ---- Tipografia (VectorText). Scale 1.0 = caracter 20×40 px ----
constexpr float TITLE_SCALE = 0.85F; // mateixa escala que el HUD del scoreboard
constexpr float ITEM_SCALE = 0.55F; // mateixa escala que les notificacions
constexpr float TEXT_SPACING = 2.0F;
// ---- Sons UI (relatius a data/sounds/), portats d'aee_arcade ----
constexpr const char* SELECT_SOUND = "ui/menu_select.wav";
constexpr const char* ACCEPT_SOUND = "ui/menu_accept.wav";
} // namespace Defaults::ServiceMenu
@@ -5,6 +5,7 @@
#include <SDL3/SDL.h>
#include <SDL3/SDL_gpu.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
@@ -390,6 +391,10 @@ namespace Rendering::GPU {
color_target.store_op = SDL_GPU_STOREOP_STORE;
render_pass_ = SDL_BeginGPURenderPass(cmd_buffer_, &color_target, 1, nullptr);
// L'scissor és per render pass: en reobrir cal restaurar-lo des del top
// de la pila si pushClip/popClip s'han usat mid-frame.
applyCurrentScissor();
SDL_BindGPUGraphicsPipeline(render_pass_, line_pipeline_.get());
// UBO de líneas usa el tamaño lógico (también del offscreen).
@@ -415,6 +420,11 @@ namespace Rendering::GPU {
SDL_ReleaseGPUBuffer(dev, vbo);
SDL_ReleaseGPUBuffer(dev, ibo);
SDL_ReleaseGPUTransferBuffer(dev, tbo);
// Buidem el batch perquè pushClip/popClip puguin emetre seccions
// separades dins el mateix frame sense re-enviar geometria.
vertices_.clear();
indices_.clear();
}
void GpuFrameRenderer::bloomPass() {
@@ -603,6 +613,51 @@ namespace Rendering::GPU {
SDL_DrawGPUPrimitives(render_pass_, 3, 1, 0, 0);
}
void GpuFrameRenderer::pushClip(int logical_x, int logical_y, int logical_w, int logical_h) {
// Convertim coordenades lògiques (espai del joc, 1280×720) a píxels
// físics del offscreen (render_w_ × render_h_). Si l'usuari hi treballa
// amb upscale (p.ex. 1920×1080), l'scissor escala proporcionalment.
const float SX = render_w_ / logical_w_;
const float SY = render_h_ / logical_h_;
SDL_Rect rect{
.x = static_cast<int>(static_cast<float>(logical_x) * SX),
.y = static_cast<int>(static_cast<float>(logical_y) * SY),
.w = std::max(0, static_cast<int>(static_cast<float>(logical_w) * SX)),
.h = std::max(0, static_cast<int>(static_cast<float>(logical_h) * SY)),
};
// Emetem tot el batch acumulat *abans* d'activar l'scissor perquè quedi
// dibuixat sense retallar.
flushBatch();
clip_stack_.push_back(rect);
applyCurrentScissor();
}
void GpuFrameRenderer::popClip() {
// Emetem el batch que s'ha acumulat *dins* del clip actiu.
flushBatch();
if (!clip_stack_.empty()) {
clip_stack_.pop_back();
}
applyCurrentScissor();
}
void GpuFrameRenderer::applyCurrentScissor() {
if (render_pass_ == nullptr) {
return;
}
SDL_Rect rect{};
if (clip_stack_.empty()) {
// Sense clips: scissor cobreix tot el offscreen.
rect.x = 0;
rect.y = 0;
rect.w = static_cast<int>(render_w_);
rect.h = static_cast<int>(render_h_);
} else {
rect = clip_stack_.back();
}
SDL_SetGPUScissor(render_pass_, &rect);
}
void GpuFrameRenderer::endFrame() {
if (cmd_buffer_ == nullptr) {
return;
@@ -94,6 +94,15 @@ namespace Rendering::GPU {
// d'UI (notificacions, panels).
void pushRect(float x, float y, float w, float h, float r, float g, float b, float a);
// Clipping rectangular per a UI (scissor a SDL_GPU). pushClip/popClip
// forcen un flush intermedi del batch i activen/restauren l'scissor del
// pase actiu. Coordenades en píxels lògics del joc (1280×720); es
// converteixen a píxels físics del offscreen automàticament. Stack
// d'scissors per a clips niats. Quan la pila queda buida, l'scissor
// torna a cobrir el target sencer.
void pushClip(int logical_x, int logical_y, int logical_w, int logical_h);
void popClip();
// endFrame: flush del batch de líneas → composite postpro → submit + presenta.
void endFrame();
@@ -168,6 +177,10 @@ namespace Rendering::GPU {
std::vector<LineVertex> vertices_;
std::vector<uint16_t> indices_;
// Pila d'scissors actius en píxels físics del offscreen. Buida = sense
// clip (full target). Cada push/pop fa un flushBatch i reaplica scissor.
std::vector<SDL_Rect> clip_stack_;
// Estado del frame en curso.
SDL_GPUCommandBuffer* cmd_buffer_{nullptr};
SDL_GPUTexture* swapchain_texture_{nullptr};
@@ -190,6 +203,7 @@ namespace Rendering::GPU {
void bloomPass(); // pre-composite: H + V passes sobre les bloom textures
void compositePass();
void applyFinalViewport();
void applyCurrentScissor(); // re-aplica el top de clip_stack_ al render_pass_
};
} // namespace Rendering::GPU
+9
View File
@@ -20,6 +20,7 @@
#include "core/resources/resource_helper.hpp"
#include "core/resources/resource_loader.hpp"
#include "core/system/notifier.hpp"
#include "core/system/service_menu.hpp"
#include "core/utils/path_utils.hpp"
#include "debug_overlay.hpp"
#include "game/config_yaml.hpp"
@@ -165,6 +166,7 @@ Director::Director(int argc, char* argv[])
cfg_->rendering);
System::Notifier::init(sdl_->getRenderer());
System::ServiceMenu::init(sdl_->getRenderer());
last_ticks_ms_ = SDL_GetTicks();
}
@@ -179,6 +181,7 @@ Director::~Director() {
// l'hem de cridar nosaltres.
current_scene_.reset();
debug_overlay_.reset();
System::ServiceMenu::destroy();
System::Notifier::destroy();
context_.reset();
sdl_.reset();
@@ -359,6 +362,9 @@ auto Director::iterate() -> SDL_AppResult {
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->update(delta_time);
}
if (auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->update(delta_time);
}
Audio::update();
// Si la swapchain no està disponible (finestra minimitzada, etc.),
@@ -372,6 +378,9 @@ auto Director::iterate() -> SDL_AppResult {
if (const auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->draw(); // toast: per damunt de tot
}
if (const auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->draw(); // service menu: per damunt fins i tot dels toasts
}
sdl_->present();
return SDL_APP_CONTINUE;
}
+44 -1
View File
@@ -10,6 +10,7 @@
#include "core/locale/locale.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/notifier.hpp"
#include "core/system/service_menu.hpp"
#include "game/config_yaml.hpp"
#include "scene_context.hpp"
@@ -19,6 +20,31 @@ using SceneType = SceneContext::SceneType;
namespace GlobalEvents {
namespace {
// Reenvia el KEY_DOWN al menu de servei si esta obert i la tecla no
// es F1-F12 ni ESC (que sempre passen com a globals). Retorna true si
// el menu l'ha consumit.
auto forwardToServiceMenu(const SDL_Event& event) -> bool {
if (event.type != SDL_EVENT_KEY_DOWN) {
return false;
}
auto* menu = System::ServiceMenu::get();
if (menu == nullptr || !menu->isOpen()) {
return false;
}
const SDL_Scancode SC = event.key.scancode;
const bool PASSTHROUGH = (SC == SDL_SCANCODE_ESCAPE) ||
(SC >= SDL_SCANCODE_F1 && SC <= SDL_SCANCODE_F12);
if (PASSTHROUGH) {
return false;
}
menu->handleEvent(event);
return true;
}
} // namespace
auto handle(const SDL_Event& event, SDLManager& sdl, SceneContext& context) -> bool {
// 1. Permitir que Input procese el evento (para hotplug de gamepads)
auto event_msg = Input::get()->handleEvent(event);
@@ -36,7 +62,15 @@ namespace GlobalEvents {
// 3. Gestió del ratolí (auto-ocultar)
Mouse::handleEvent(event);
// 4. Procesar acciones globales directamente desde eventos SDL
// 4. Service Menu (F12): consumeix tot KEY_DOWN excepte tecles de
// funció (F1-F12) i ESC, que continuen sent globals (zoom, fullscreen,
// vsync, AA, postfx, locale, exit prompt). Aixi el menu captura
// ENTER/BACKSPACE/UP/DOWN/LEFT/RIGHT i lletres mentre esta obert.
if (forwardToServiceMenu(event)) {
return true;
}
// 5. Procesar acciones globales directamente desde eventos SDL
// (NO usar Input::checkAction() para evitar desfase de timing)
if (event.type == SDL_EVENT_KEY_DOWN) {
switch (event.key.scancode) {
@@ -84,6 +118,15 @@ namespace GlobalEvents {
return true;
}
case SDL_SCANCODE_F12: {
// Toggle del menu de servei. Sempre passa com a global
// (alterna obert/tancat des de qualsevol escena).
if (auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->toggle();
}
return true;
}
case SDL_SCANCODE_ESCAPE: {
// Doble pulsació per confirmar sortida: la primera ESC
// dispara un toast d'avís; només si aquest toast concret
+421
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
// service_menu.hpp - Menu de servei (singleton)
// © 2026 JailDesigner
//
// Overlay de configuracio global accessible amb F12 des de qualsevol escena
// (LOGO, TITLE, GAME). Captura tots els KEY_DOWN excepte F1-F12 i ESC, que
// continuen arribant a GlobalEvents. Mentre esta obert, GameScene::update()
// fa early return per pausar el joc; LOGO i TITLE continuen renderitzant-se
// sota el menu.
//
// Arquitectura inspirada en aee_arcade service_menu.{hpp,cpp}: pila de
// pagines amb cursor, animacio open/close amb easing easeOutQuad i clipping
// del contingut mentre la caixa creix/decreix.
//
// API singleton equivalent a Notifier: init() al startup amb un renderer,
// get() retorna el punter, destroy() al teardown.
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "core/graphics/vector_text.hpp"
#include "core/rendering/render_context.hpp"
namespace System {
class ServiceMenu {
public:
// Tipus d'item de menu. En aquesta iteracio nomes s'usen SUBMENU i
// LABEL; la resta queden reservats per a iteracions futures (toggles
// de vsync/zoom, picker d'idioma, restart, exit...).
enum class Kind : std::uint8_t {
LABEL, // No interactiu, nomes es dibuixa
TOGGLE, // bool flip — reservat
CYCLE, // index amb modul — reservat
INT_RANGE, // step ± — reservat
SUBMENU, // pushPage en activar — usat
ACTION // call al lambda en activar — reservat
};
struct Item {
Kind kind = Kind::LABEL;
std::string label_key; // Clau de locale
bool selectable = true;
std::function<void()> on_activate;
};
struct Page {
std::string title_key;
std::vector<Item> items;
std::size_t cursor = 0;
};
// Inicialitza el singleton amb el renderer global (propietat del
// Director via SDLManager). Posterior get() retorna instancia valida.
static void init(Rendering::Renderer* renderer);
static void destroy();
[[nodiscard]] static auto get() -> ServiceMenu*;
// F12: alterna obrir/tancar amb animacio.
void toggle();
[[nodiscard]] auto isOpen() const -> bool;
void update(float delta_time);
void draw() const;
// Processa el KEY_DOWN. Retorna true si l'ha consumit (UP/DOWN/ENTER/
// RIGHT/BACKSPACE/LEFT mentre esta obert). false en qualsevol altre cas.
auto handleEvent(const SDL_Event& event) -> bool;
private:
explicit ServiceMenu(Rendering::Renderer* renderer);
void buildRootPage();
void pushSubmenuPlaceholder(const std::string& title_key);
void pushPage(Page page);
void popPage();
void moveCursor(int direction);
void activateCurrent();
// Alçada objectiu de la caixa per a la pagina superior (sense animacio).
[[nodiscard]] auto computeTargetHeight() const -> float;
// Y (top) de l'item index dins una caixa col·locada a box_y.
[[nodiscard]] static auto computeItemTopY(float box_y, std::size_t index) -> float;
Rendering::Renderer* renderer_;
Graphics::VectorText text_;
std::vector<Page> stack_;
bool open_ = false;
bool closing_ = false;
float open_anim_ = 0.0F; // 0..1 raw (sense easing)
float animated_h_ = 0.0F; // Alçada animada amb smoothing exponencial
// Estat del highlight (rectangle del cursor). Es lerpa cap a l'item
// actiu amb ease-out exponencial; quan el cursor "salta" (open o
// push/pop de pagina), s'enganxa directament al nou objectiu.
float highlight_y_ = 0.0F;
float highlight_h_ = 0.0F;
bool highlight_snap_ = true;
static std::unique_ptr<ServiceMenu> instance;
};
} // namespace System
+8
View File
@@ -13,6 +13,7 @@
#include "core/input/input.hpp"
#include "core/locale/locale.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
#include "game/stage_system/stage_loader.hpp"
#include "game/systems/collision_system.hpp"
#include "game/systems/continue_system.hpp"
@@ -182,6 +183,13 @@ void GameScene::handleEvent(const SDL_Event& event) {
}
void GameScene::update(float delta_time) {
// Pausa global: mentre el menu de servei esta obert, congelem la lògica
// de joc. El draw() segueix executant-se per a mantenir l'escena visible
// sota el menu.
if (const auto* menu = System::ServiceMenu::get(); menu != nullptr && menu->isOpen()) {
return;
}
// Orquestador delgado: cada paso vive en su propia función para
// mantener update() legible y reducir complejidad cognitiva.
stepPhysics(delta_time);