diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index eefa10a..45b0fd5 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -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" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 1d7fdbe..af0910c 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -49,6 +49,8 @@ service_menu: title: "SERVICE MENU" video: "VIDEO" audio: "AUDIO" + options: "OPTIONS" + system: "SYSTEM" controls: "CONTROLS" back: "BACK" exit: "EXIT GAME" diff --git a/data/sounds/ui/menu_accept.wav b/data/sounds/ui/menu_accept.wav new file mode 100644 index 0000000..3cebb3e Binary files /dev/null and b/data/sounds/ui/menu_accept.wav differ diff --git a/data/sounds/ui/menu_select.wav b/data/sounds/ui/menu_select.wav new file mode 100644 index 0000000..79523b9 Binary files /dev/null and b/data/sounds/ui/menu_select.wav differ diff --git a/source/core/defaults/service_menu.hpp b/source/core/defaults/service_menu.hpp new file mode 100644 index 0000000..d79342b --- /dev/null +++ b/source/core/defaults/service_menu.hpp @@ -0,0 +1,55 @@ +// service_menu.hpp - Constants del menu de servei (F12) +// © 2026 JailDesigner + +#pragma once + +#include + +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 diff --git a/source/core/rendering/gpu/gpu_frame_renderer.cpp b/source/core/rendering/gpu/gpu_frame_renderer.cpp index 21b99aa..60cd8ac 100644 --- a/source/core/rendering/gpu/gpu_frame_renderer.cpp +++ b/source/core/rendering/gpu/gpu_frame_renderer.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -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(static_cast(logical_x) * SX), + .y = static_cast(static_cast(logical_y) * SY), + .w = std::max(0, static_cast(static_cast(logical_w) * SX)), + .h = std::max(0, static_cast(static_cast(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(render_w_); + rect.h = static_cast(render_h_); + } else { + rect = clip_stack_.back(); + } + SDL_SetGPUScissor(render_pass_, &rect); + } + void GpuFrameRenderer::endFrame() { if (cmd_buffer_ == nullptr) { return; diff --git a/source/core/rendering/gpu/gpu_frame_renderer.hpp b/source/core/rendering/gpu/gpu_frame_renderer.hpp index d3809ab..d599db0 100644 --- a/source/core/rendering/gpu/gpu_frame_renderer.hpp +++ b/source/core/rendering/gpu/gpu_frame_renderer.hpp @@ -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 vertices_; std::vector 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 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 diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index b77e7d6..e52825f 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -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; } diff --git a/source/core/system/global_events.cpp b/source/core/system/global_events.cpp index 3839b6a..6b06f25 100644 --- a/source/core/system/global_events.cpp +++ b/source/core/system/global_events.cpp @@ -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 diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp new file mode 100644 index 0000000..5f92ba7 --- /dev/null +++ b/source/core/system/service_menu.cpp @@ -0,0 +1,421 @@ +// service_menu.cpp - Implementacio del menu de servei +// © 2026 JailDesigner + +#include "core/system/service_menu.hpp" + +#include +#include +#include +#include + +#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(color.r) / 255.0F, static_cast(color.g) / 255.0F, static_cast(color.b) / 255.0F, static_cast(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::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(direction + static_cast(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(page.items.size()); + if (N > 0) { + h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y; + } + return static_cast(h); + } + + auto ServiceMenu::computeItemTopY(float box_y, std::size_t index) -> float { + using namespace Defaults::ServiceMenu; + const float ITEMS_Y0 = box_y + + static_cast(GAP_Y) + + static_cast(TITLE_HEIGHT) + + static_cast(GAP_Y) + + static_cast(SEPARATOR_HEIGHT) + + static_cast(GAP_Y); + return ITEMS_Y0 + (static_cast(index) * static_cast(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(HIGHLIGHT_PAD_Y); + const float TARGET_H = static_cast(ITEM_HEIGHT) + (2.0F * static_cast(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(HIGHLIGHT_THICKNESS); + const auto L = static_cast(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(CORNER_THICKNESS); + const auto AH = static_cast(CORNER_ARM_H); + const auto AV = static_cast(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(BOX_WIDTH)) * 0.5F; + const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F; + const auto BOX_W = static_cast(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(BOX_X + static_cast(CORNER_THICKNESS)); + const int CLIP_Y = static_cast(BOX_Y + static_cast(CORNER_THICKNESS)); + const int CLIP_W = static_cast(BOX_W - (2.0F * static_cast(CORNER_THICKNESS))); + const int CLIP_H = std::max(0, static_cast(BOX_H - (2.0F * static_cast(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(GAP_Y) + (static_cast(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(GAP_Y) + static_cast(TITLE_HEIGHT) + (static_cast(GAP_Y) * 0.5F); + fillRect(renderer_, + BOX_X + static_cast(GAP_Y), + SEP_Y, + BOX_W - (2.0F * static_cast(GAP_Y)), + static_cast(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(HIGHLIGHT_PAD_X); + const float HL_W = BOX_W - (2.0F * static_cast(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(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 diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp new file mode 100644 index 0000000..26d6ccf --- /dev/null +++ b/source/core/system/service_menu.hpp @@ -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 + +#include +#include +#include +#include +#include +#include + +#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 on_activate; + }; + + struct Page { + std::string title_key; + std::vector 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 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 instance; + }; + +} // namespace System diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index fda86d1..798c5b2 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -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);