From 443b461974061133ed1e84296d5a66afac8dd3e8 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 11:25:09 +0200 Subject: [PATCH 1/6] feat(service-menu): esquelet amb F12, brackets sci-fi i highlight animat --- data/locale/ca.yaml | 2 + data/locale/en.yaml | 2 + data/sounds/ui/menu_accept.wav | Bin 0 -> 2806 bytes data/sounds/ui/menu_select.wav | Bin 0 -> 1978 bytes source/core/defaults/service_menu.hpp | 55 +++ .../core/rendering/gpu/gpu_frame_renderer.cpp | 55 +++ .../core/rendering/gpu/gpu_frame_renderer.hpp | 14 + source/core/system/director.cpp | 9 + source/core/system/global_events.cpp | 45 +- source/core/system/service_menu.cpp | 421 ++++++++++++++++++ source/core/system/service_menu.hpp | 112 +++++ source/game/scenes/game_scene.cpp | 8 + 12 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 data/sounds/ui/menu_accept.wav create mode 100644 data/sounds/ui/menu_select.wav create mode 100644 source/core/defaults/service_menu.hpp create mode 100644 source/core/system/service_menu.cpp create mode 100644 source/core/system/service_menu.hpp 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 0000000000000000000000000000000000000000..3cebb3e32a8378deac9ad642d3fe9f50ea359bb1 GIT binary patch literal 2806 zcmeHJ*HT(R7(U)*a+OEOdnB=gihvXWV?;z0YzV>;Q4mpzf~c_}3W|V=1xv736bsf@ z$us2NbJRgS%*2^Yu2_b-`$T_M5q7$h+(1}Ax*BtKYzk7ECYV|5sdg}U>~p= zDK5Q*ZxR>r*wMl6_U6Xg%2IePI6Li~oERJVIp}iq+AQ5&ordJ%W&HH`aBpYx*Lq|*JRh2!@l8#Pj}8yHoPBm{ zkJ)7G(CcuGx*2d3wKA!svZB09B;fNnY*ukmA)S_&lb!j^eVafy2MA{^vJ{>V&dvaz z@loJI;vhaxIC6v|1{|S)&*iY02qz!mXzy>XF3w^{`@7rGjn$RKg}H!#+Us$Hu7Unn zx*F>hNLMB35(!Ir+!7X(QCL7jx;PIc9YWR<9p4K&$+A+?IlQ~RIzKxBI$P28)#b&7 zP{8k-^0>hrL?_7|l@jRG%79J<&=HjKI3=uNL?xPpB2?j2s$QCGGPFO3l}Z)o;qLnK{Pg5-e`gD2zBnHWK;|Bl`9S}x%n4OeP*o19 z$jliib5M13cavn!V^(0!*a%_Hiy1^PDY7EWdDkez=)4_eN^{;H`M)&h;&F1G2RXGl zsRsGcoMXy!?xZ^Be`5|Db2y%9bI42Z)#RkjNq!;H=8(^4`dE?OB7Nt;Qy!1~#|s&n z1yll5Eub;My9ech4`qkVViq&X$X6V?30VQ*8Al~&7)lrP3cJB2tEwcDO0n1^?`($fPl zCR3-;Xz1u@*X!HbbUIyYD~{t@tyTl8g(s4ts?Ir%A8TvR%Yv}6>i*7RnU$}CAS~hcZ7eANCE<$@3TA>bzUe#x z?RNVScmVE!zrY{hj?e-e4yV((zV33l+-~;<;PH68Ua!yR`w95{{!L&D2m}J#z|ElF z@ArDWe?Yg}?R4~4=o$T$ltQ(DNude?JAf$W$D`43I2;T#a$;A>i5(>nxvUE#We`Tn zG+`W_D2gFqG8vD}#F}g?@s3`r9Y#r#t|l6-MKGr~kM`M6Ng_kwl#Zs;`K)@6Z{K(V zODvV6lWOG(DeHn%ZNUt!I%||S`W?~=N#z=gI9MGk@}zot$a2kdv(m)WP6IyHQ;FT# zk(#w*loBU0N@C{*>HMxX7S&IY=bU2m>o*OVXh-U6rX+T4k&59mM%S1DMAaB4nkU|W z%9N>oMD_iwBC4~u#~l9=RW((kMSDPvs?9ltRi>!UunL=tmdl%{^7X%3rTV`hswS&u z)o&S9y{XEi{(n)$0^qE&-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 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); From 71c43ec6fed01982608e0ba44cbcae61cee517bb Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 11:37:36 +0200 Subject: [PATCH 2/6] feat(service-menu): pobla VIDEO amb zoom, fullscreen, vsync, AA i postfx --- data/locale/ca.yaml | 9 ++ data/locale/en.yaml | 9 ++ source/core/defaults/service_menu.hpp | 1 + source/core/rendering/sdl_manager.hpp | 2 + source/core/system/director.cpp | 2 +- source/core/system/service_menu.cpp | 195 +++++++++++++++++++++----- source/core/system/service_menu.hpp | 21 ++- 7 files changed, 200 insertions(+), 39 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 45b0fd5..8f124df 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -55,3 +55,12 @@ service_menu: controls: "CONTROLS" back: "ENRERE" exit: "EIXIR DEL JOC" + # Items del submenu VIDEO + video_zoom: "ZOOM" + video_fullscreen: "PANTALLA COMPLETA" + video_vsync: "VSYNC" + video_aa: "ANTIALIAS" + video_postfx: "POSTPROCESSAT" + # Valors comuns + value_on: "ACTIU" + value_off: "INACTIU" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index af0910c..45efd8c 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -54,3 +54,12 @@ service_menu: controls: "CONTROLS" back: "BACK" exit: "EXIT GAME" + # Items of VIDEO submenu + video_zoom: "ZOOM" + video_fullscreen: "FULLSCREEN" + video_vsync: "VSYNC" + video_aa: "ANTIALIAS" + video_postfx: "POSTPROCESS" + # Common values + value_on: "ON" + value_off: "OFF" diff --git a/source/core/defaults/service_menu.hpp b/source/core/defaults/service_menu.hpp index d79342b..72e02c8 100644 --- a/source/core/defaults/service_menu.hpp +++ b/source/core/defaults/service_menu.hpp @@ -32,6 +32,7 @@ namespace Defaults::ServiceMenu { 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 + constexpr int TEXT_INSET_X = 16; // marge intern del text dins del highlight (label esq / valor dre) // ---- Colors RGBA ---- constexpr SDL_Color BG_COLOR{.r = 0, .g = 12, .b = 24, .a = 215}; diff --git a/source/core/rendering/sdl_manager.hpp b/source/core/rendering/sdl_manager.hpp index ec1ee35..239d537 100644 --- a/source/core/rendering/sdl_manager.hpp +++ b/source/core/rendering/sdl_manager.hpp @@ -47,6 +47,8 @@ class SDLManager { // Getters auto getRenderer() -> Rendering::Renderer* { return &gpu_renderer_; } [[nodiscard]] auto getScaleFactor() const -> float { return zoom_factor_; } + [[nodiscard]] auto isFullscreen() const -> bool { return is_fullscreen_; } + [[nodiscard]] auto isPostFxEnabled() const -> bool { return gpu_renderer_.isPostFxEnabled(); } // [NUEVO] Actualitzar context de renderizado (factor de scale global) void updateRenderingContext() const; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index e52825f..0094ce1 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -166,7 +166,7 @@ Director::Director(int argc, char* argv[]) cfg_->rendering); System::Notifier::init(sdl_->getRenderer()); - System::ServiceMenu::init(sdl_->getRenderer()); + System::ServiceMenu::init(sdl_->getRenderer(), sdl_.get()); last_ticks_ms_ = SDL_GetTicks(); } diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index 5f92ba7..198133b 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -6,12 +6,16 @@ #include #include #include +#include #include #include "core/audio/audio.hpp" +#include "core/config/engine_config.hpp" #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" +#include "core/rendering/sdl_manager.hpp" #include "core/types.hpp" +#include "game/config_yaml.hpp" namespace { @@ -50,8 +54,8 @@ namespace System { std::unique_ptr ServiceMenu::instance; - void ServiceMenu::init(Rendering::Renderer* renderer) { - instance.reset(new ServiceMenu(renderer)); + void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl) { + instance.reset(new ServiceMenu(renderer, sdl)); } void ServiceMenu::destroy() { @@ -62,8 +66,9 @@ namespace System { return instance.get(); } - ServiceMenu::ServiceMenu(Rendering::Renderer* renderer) + ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl) : renderer_(renderer), + sdl_(sdl), text_(renderer) {} auto ServiceMenu::isOpen() const -> bool { @@ -87,26 +92,33 @@ namespace System { playAcceptSound(); } + namespace { + + // Helper local: construeix un item de tipus SUBMENU amb el callback + // d'entrada. Es manté local a aquesta TU per a poder construir la + // pagina arrel a buildRootPage sense designed-initializers parcials + // (clang-tidy es queixa quan en falten). + auto makeSubmenu(const std::string& label_key, std::function on_activate) -> ServiceMenu::Item { + return ServiceMenu::Item{ + .kind = ServiceMenu::Kind::SUBMENU, + .label_key = label_key, + .selectable = true, + .on_activate = std::move(on_activate), + .get_value_text = {}, + .on_change = {}, + }; + } + + } // namespace + 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"); }}, + makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), + makeSubmenu("service_menu.audio", [this] { pushSubmenuPlaceholder("service_menu.audio"); }), + makeSubmenu("service_menu.options", [this] { pushSubmenuPlaceholder("service_menu.options"); }), + makeSubmenu("service_menu.system", [this] { pushSubmenuPlaceholder("service_menu.system"); }), }; stack_.clear(); stack_.push_back(std::move(root)); @@ -120,6 +132,71 @@ namespace System { pushPage(std::move(page)); } + auto ServiceMenu::buildVideoPage() const -> Page { + // Helper: localitza ON/OFF per a TOGGLE items. + auto on_off_text = [](bool v) -> std::string { + return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); + }; + + SDLManager* sdl = sdl_; + + Page page; + page.title_key = "service_menu.video"; + page.items = { + // ZOOM (INT_RANGE-style: ± delega a sdl.increase/decreaseWindowSize). + Item{ + .kind = Kind::INT_RANGE, + .label_key = "service_menu.video_zoom", + .selectable = true, + .on_activate = {}, + .get_value_text = [sdl] { return std::format("{:.1f}X", sdl->getScaleFactor()); }, + .on_change = [sdl](int dir) { + if (dir > 0) { + sdl->increaseWindowSize(); + } else { + sdl->decreaseWindowSize(); + } }, + }, + // FULLSCREEN + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.video_fullscreen", + .selectable = true, + .on_activate = {}, + .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isFullscreen()); }, + .on_change = [sdl](int) { sdl->toggleFullscreen(); }, + }, + // VSYNC + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.video_vsync", + .selectable = true, + .on_activate = {}, + .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.vsync != 0); }, + .on_change = [sdl](int) { sdl->toggleVSync(); }, + }, + // ANTIALIAS + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.video_aa", + .selectable = true, + .on_activate = {}, + .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.antialias != 0); }, + .on_change = [sdl](int) { sdl->toggleAntialias(); }, + }, + // POSTPROCESSAT + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.video_postfx", + .selectable = true, + .on_activate = {}, + .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isPostFxEnabled()); }, + .on_change = [sdl](int) { sdl->togglePostFx(); }, + }, + }; + return page; + } + void ServiceMenu::pushPage(Page page) { stack_.push_back(std::move(page)); // El cursor salta a una pagina nova: enganxem el highlight per a @@ -163,6 +240,12 @@ namespace System { } void ServiceMenu::activateCurrent() { + // ENTER = canvi de valor cap endavant (equivalent a RIGHT). Per a + // SUBMENU/ACTION entra/activa; per a TOGGLE/CYCLE/INT_RANGE incrementa. + changeValue(+1); + } + + void ServiceMenu::changeValue(int direction) { if (stack_.empty()) { return; } @@ -174,11 +257,25 @@ namespace System { 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(); + switch (item.kind) { + case Kind::TOGGLE: + case Kind::CYCLE: + case Kind::INT_RANGE: + if (item.on_change) { + item.on_change(direction); + playAcceptSound(); + } + break; + case Kind::SUBMENU: + case Kind::ACTION: + // Nomes +1 entra/activa: LEFT no fa res (BACKSPACE per a sortir). + if (direction > 0 && item.on_activate) { + item.on_activate(); + playAcceptSound(); + } + break; + case Kind::LABEL: + break; } } @@ -195,11 +292,15 @@ namespace System { 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_RIGHT: + changeValue(+1); + return true; case SDL_SCANCODE_LEFT: + changeValue(-1); + return true; + case SDL_SCANCODE_BACKSPACE: popPage(); return true; default: @@ -400,19 +501,45 @@ namespace System { drawHighlightRect(renderer_, HL_X, highlight_y_, HL_W, highlight_h_); } - // Llista d'items: centrats horitzontalment, color groc per al seleccionat. + // Llista d'items. + // - Items amb valor (TOGGLE/CYCLE/INT_RANGE): label esquerra + valor dreta dins del highlight. + // - Items sense valor (SUBMENU/ACTION/LABEL): label centrat. + const float HL_LEFT = BOX_X + static_cast(HIGHLIGHT_PAD_X); + const float HL_RIGHT = BOX_X + BOX_W - static_cast(HIGHLIGHT_PAD_X); + const float TEXT_TOP_OFFSET = Graphics::VectorText::getTextHeight(ITEM_SCALE) * 0.5F; 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 std::string LABEL = 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); + + if (item.get_value_text) { + // Layout dues columnes: label esquerra, valor dreta. + const std::string VALUE = item.get_value_text(); + const float TEXT_TOP_Y = ITEM_CY - TEXT_TOP_OFFSET; + const float VALUE_W = Graphics::VectorText::getTextWidth(VALUE, ITEM_SCALE, TEXT_SPACING); + text_.render(LABEL, + Vec2{.x = HL_LEFT + static_cast(TEXT_INSET_X), .y = TEXT_TOP_Y}, + ITEM_SCALE, + TEXT_SPACING, + 1.0F, + COL); + text_.render(VALUE, + Vec2{.x = HL_RIGHT - static_cast(TEXT_INSET_X) - VALUE_W, .y = TEXT_TOP_Y}, + ITEM_SCALE, + TEXT_SPACING, + 1.0F, + COL); + } else { + // Layout simple: label centrat. + text_.renderCentered(LABEL, + Vec2{.x = CENTER_X, .y = ITEM_CY}, + ITEM_SCALE, + TEXT_SPACING, + 1.0F, + COL); + } } renderer_->popClip(); diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index 26d6ccf..cd9e0f7 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -28,6 +28,8 @@ #include "core/graphics/vector_text.hpp" #include "core/rendering/render_context.hpp" +class SDLManager; + namespace System { class ServiceMenu { @@ -48,7 +50,12 @@ namespace System { Kind kind = Kind::LABEL; std::string label_key; // Clau de locale bool selectable = true; + // SUBMENU / ACTION: callback en ENTER / RIGHT. std::function on_activate; + // TOGGLE / CYCLE / INT_RANGE: text del valor actual (renderitzat a la dreta). + std::function get_value_text; + // TOGGLE / CYCLE / INT_RANGE: callback amb +1 (RIGHT/ENTER) o -1 (LEFT). + std::function on_change; }; struct Page { @@ -57,9 +64,10 @@ namespace System { 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); + // Inicialitza el singleton amb el renderer global i l'SDLManager (per + // a operar amb video toggles: fullscreen, vsync, AA, postfx, zoom). + // Tots dos son propietat del Director i sobreviuen al menu. + static void init(Rendering::Renderer* renderer, SDLManager* sdl); static void destroy(); [[nodiscard]] static auto get() -> ServiceMenu*; @@ -75,14 +83,18 @@ namespace System { auto handleEvent(const SDL_Event& event) -> bool; private: - explicit ServiceMenu(Rendering::Renderer* renderer); + ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl); void buildRootPage(); void pushSubmenuPlaceholder(const std::string& title_key); + [[nodiscard]] auto buildVideoPage() const -> Page; void pushPage(Page page); void popPage(); void moveCursor(int direction); void activateCurrent(); + // RIGHT (direction=+1) / LEFT (direction=-1). Per a TOGGLE/CYCLE/INT_RANGE + // crida on_change. Per a SUBMENU/ACTION nomes +1 (entra/activa). + void changeValue(int direction); // Alçada objectiu de la caixa per a la pagina superior (sense animacio). [[nodiscard]] auto computeTargetHeight() const -> float; @@ -91,6 +103,7 @@ namespace System { [[nodiscard]] static auto computeItemTopY(float box_y, std::size_t index) -> float; Rendering::Renderer* renderer_; + SDLManager* sdl_; Graphics::VectorText text_; std::vector stack_; From 56d7d4af52d77b2f25da0bbaafb23736d4154f3d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 11:49:14 +0200 Subject: [PATCH 3/6] feat(service-menu): pobla AUDIO amb toggles i sliders de volum --- data/locale/ca.yaml | 7 ++ data/locale/en.yaml | 7 ++ source/core/audio/audio.cpp | 21 +++++- source/core/audio/audio.hpp | 8 ++ source/core/system/service_menu.cpp | 109 +++++++++++++++++++++++++++- source/core/system/service_menu.hpp | 1 + 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 8f124df..565aeab 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -61,6 +61,13 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESSAT" + # Items del submenu AUDIO + audio_master: "AUDIO" + audio_master_volume: "VOLUM GENERAL" + audio_music: "MUSICA" + audio_music_volume: "VOLUM MUSICA" + audio_sound: "EFECTES" + audio_sound_volume: "VOLUM EFECTES" # Valors comuns value_on: "ACTIU" value_off: "INACTIU" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 45efd8c..d107c96 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -60,6 +60,13 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESS" + # Items of AUDIO submenu + audio_master: "AUDIO" + audio_master_volume: "MASTER VOLUME" + audio_music: "MUSIC" + audio_music_volume: "MUSIC VOLUME" + audio_sound: "SOUNDS" + audio_sound_volume: "SOUND VOLUME" # Common values value_on: "ON" value_off: "OFF" diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index 8bb79d0..d5fbd5b 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -238,14 +238,27 @@ auto Audio::effectiveVolume(float volume, bool channel_enabled) const -> float { return (enabled_ && channel_enabled) ? volume * config_.volume : 0.0F; } -// Estableix el volum dels sons (float 0.0..1.0) +// Estableix el volum dels sons (float 0.0..1.0). Actualitza el valor cachejat +// a config_ perquè els getters i les re-aplicacions internes (enableSound, +// setMasterVolume) puguin tornar al volum que l'usuari va triar. void Audio::setSoundVolume(float sound_volume, Group group) { - engine_->setSoundVolume(effectiveVolume(sound_volume, sound_enabled_), static_cast(group)); + config_.sound_volume = std::clamp(sound_volume, MIN_VOLUME, MAX_VOLUME); + engine_->setSoundVolume(effectiveVolume(config_.sound_volume, sound_enabled_), static_cast(group)); } -// Estableix el volum de la música (float 0.0..1.0) +// Estableix el volum de la música (float 0.0..1.0). Cf. setSoundVolume. void Audio::setMusicVolume(float music_volume) { - engine_->setMusicVolume(effectiveVolume(music_volume, music_enabled_)); + config_.music_volume = std::clamp(music_volume, MIN_VOLUME, MAX_VOLUME); + engine_->setMusicVolume(effectiveVolume(config_.music_volume, music_enabled_)); +} + +// Estableix el volum master (multiplicador aplicat a sound + music). Re-aplica +// els canals perquè el canvi tingui efecte immediat sense esperar al següent +// setSoundVolume/setMusicVolume explícit. +void Audio::setMasterVolume(float master_volume) { + config_.volume = std::clamp(master_volume, MIN_VOLUME, MAX_VOLUME); + setSoundVolume(config_.sound_volume); + setMusicVolume(config_.music_volume); } // Aplica una nueva configuración (substitueix la config cachejada i reaplica enables/volums) diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index e9f522f..e7fed27 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -101,6 +101,14 @@ class Audio { // --- Control de volum (API interna: float 0.0..1.0) --- void setSoundVolume(float volume, Group group = Group::ALL); // Ajusta el volum dels efectes void setMusicVolume(float volume); // Ajusta el volum de la música + void setMasterVolume(float volume); // Ajusta el master (re-aplica sound + music) + + // Getters dels volums actuals (lectura de la config_ cachejada). Reflexen + // el valor que l'usuari ha triat l'última vegada, independent del gating + // d'enabled/channel. + [[nodiscard]] auto getMasterVolume() const -> float { return config_.volume; } + [[nodiscard]] auto getSoundVolume() const -> float { return config_.sound_volume; } + [[nodiscard]] auto getMusicVolume() const -> float { return config_.music_volume; } // --- Helpers de conversió para la capa de presentació --- // UI (menús, notificacions) manega enters 0..100; internament viu float 0..1. diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index 198133b..a2d6819 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -11,6 +11,7 @@ #include "core/audio/audio.hpp" #include "core/config/engine_config.hpp" +#include "core/defaults/audio.hpp" #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" @@ -116,7 +117,7 @@ namespace System { root.title_key = "service_menu.title"; root.items = { makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), - makeSubmenu("service_menu.audio", [this] { pushSubmenuPlaceholder("service_menu.audio"); }), + makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }), makeSubmenu("service_menu.options", [this] { pushSubmenuPlaceholder("service_menu.options"); }), makeSubmenu("service_menu.system", [this] { pushSubmenuPlaceholder("service_menu.system"); }), }; @@ -197,6 +198,112 @@ namespace System { return page; } + auto ServiceMenu::buildAudioPage() -> Page { + auto on_off_text = [](bool v) -> std::string { + return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); + }; + + // Aplica un step de volum (±VOLUME_STEP) a un valor 0..1 i retorna el + // resultat clampat. El motor s'encarrega d'aplicar-lo amb el getter. + auto step_volume = [](float current, int dir) -> float { + const float STEP = Defaults::Audio::VOLUME_STEP; + return std::clamp(current + (static_cast(dir) * STEP), 0.0F, 1.0F); + }; + + Page page; + page.title_key = "service_menu.audio"; + page.items = { + // AUDIO (master ON/OFF) + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.audio_master", + .selectable = true, + .on_activate = {}, + .get_value_text = [on_off_text] { + const Audio* a = Audio::get(); + return on_off_text(a != nullptr && a->isEnabled()); }, + .on_change = [](int) { + if (auto* a = Audio::get(); a != nullptr) { + a->toggleEnabled(); + } }, + }, + // VOLUM GENERAL (master) + Item{ + .kind = Kind::INT_RANGE, + .label_key = "service_menu.audio_master_volume", + .selectable = true, + .on_activate = {}, + .get_value_text = [] { + const Audio* a = Audio::get(); + const float V = (a != nullptr) ? a->getMasterVolume() : 0.0F; + return std::to_string(Audio::toPercent(V)); }, + .on_change = [step_volume](int dir) { + if (auto* a = Audio::get(); a != nullptr) { + a->setMasterVolume(step_volume(a->getMasterVolume(), dir)); + } }, + }, + // MUSICA ON/OFF + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.audio_music", + .selectable = true, + .on_activate = {}, + .get_value_text = [on_off_text] { + const Audio* a = Audio::get(); + return on_off_text(a != nullptr && a->isMusicEnabled()); }, + .on_change = [](int) { + if (auto* a = Audio::get(); a != nullptr) { + a->toggleMusic(); + } }, + }, + // VOLUM MUSICA + Item{ + .kind = Kind::INT_RANGE, + .label_key = "service_menu.audio_music_volume", + .selectable = true, + .on_activate = {}, + .get_value_text = [] { + const Audio* a = Audio::get(); + const float V = (a != nullptr) ? a->getMusicVolume() : 0.0F; + return std::to_string(Audio::toPercent(V)); }, + .on_change = [step_volume](int dir) { + if (auto* a = Audio::get(); a != nullptr) { + a->setMusicVolume(step_volume(a->getMusicVolume(), dir)); + } }, + }, + // SONS ON/OFF + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.audio_sound", + .selectable = true, + .on_activate = {}, + .get_value_text = [on_off_text] { + const Audio* a = Audio::get(); + return on_off_text(a != nullptr && a->isSoundEnabled()); }, + .on_change = [](int) { + if (auto* a = Audio::get(); a != nullptr) { + a->toggleSound(); + } }, + }, + // VOLUM SONS + Item{ + .kind = Kind::INT_RANGE, + .label_key = "service_menu.audio_sound_volume", + .selectable = true, + .on_activate = {}, + .get_value_text = [] { + const Audio* a = Audio::get(); + const float V = (a != nullptr) ? a->getSoundVolume() : 0.0F; + return std::to_string(Audio::toPercent(V)); }, + .on_change = [step_volume](int dir) { + if (auto* a = Audio::get(); a != nullptr) { + a->setSoundVolume(step_volume(a->getSoundVolume(), dir)); + } }, + }, + }; + return page; + } + void ServiceMenu::pushPage(Page page) { stack_.push_back(std::move(page)); // El cursor salta a una pagina nova: enganxem el highlight per a diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index cd9e0f7..24210cc 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -88,6 +88,7 @@ namespace System { void buildRootPage(); void pushSubmenuPlaceholder(const std::string& title_key); [[nodiscard]] auto buildVideoPage() const -> Page; + [[nodiscard]] static auto buildAudioPage() -> Page; void pushPage(Page page); void popPage(); void moveCursor(int direction); From 8c21345f14bb8d1ba15c6ab282232a3dd2fa4129 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 11:56:11 +0200 Subject: [PATCH 4/6] feat(service-menu): pobla OPCIONS amb idioma i toggle del HUD de debug --- data/locale/ca.yaml | 3 ++ data/locale/en.yaml | 3 ++ source/core/system/director.cpp | 2 +- source/core/system/service_menu.cpp | 50 ++++++++++++++++++++++++++--- source/core/system/service_menu.hpp | 14 +++++--- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 565aeab..a49f63b 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -61,6 +61,9 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESSAT" + # Items del submenu OPCIONS + options_language: "IDIOMA" + options_show_info: "MOSTRAR INFO" # Items del submenu AUDIO audio_master: "AUDIO" audio_master_volume: "VOLUM GENERAL" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index d107c96..a10340e 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -60,6 +60,9 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESS" + # Items of OPTIONS submenu + options_language: "LANGUAGE" + options_show_info: "SHOW INFO" # Items of AUDIO submenu audio_master: "AUDIO" audio_master_volume: "MASTER VOLUME" diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 0094ce1..0422a14 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -166,7 +166,7 @@ Director::Director(int argc, char* argv[]) cfg_->rendering); System::Notifier::init(sdl_->getRenderer()); - System::ServiceMenu::init(sdl_->getRenderer(), sdl_.get()); + System::ServiceMenu::init(sdl_->getRenderer(), sdl_.get(), debug_overlay_.get()); last_ticks_ms_ = SDL_GetTicks(); } diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index a2d6819..5e5cef6 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -15,6 +15,7 @@ #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" +#include "core/system/debug_overlay.hpp" #include "core/types.hpp" #include "game/config_yaml.hpp" @@ -55,8 +56,8 @@ namespace System { std::unique_ptr ServiceMenu::instance; - void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl) { - instance.reset(new ServiceMenu(renderer, sdl)); + void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) { + instance.reset(new ServiceMenu(renderer, sdl, debug_overlay)); } void ServiceMenu::destroy() { @@ -67,9 +68,10 @@ namespace System { return instance.get(); } - ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl) + ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) : renderer_(renderer), sdl_(sdl), + debug_overlay_(debug_overlay), text_(renderer) {} auto ServiceMenu::isOpen() const -> bool { @@ -118,7 +120,7 @@ namespace System { root.items = { makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }), - makeSubmenu("service_menu.options", [this] { pushSubmenuPlaceholder("service_menu.options"); }), + makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }), makeSubmenu("service_menu.system", [this] { pushSubmenuPlaceholder("service_menu.system"); }), }; stack_.clear(); @@ -304,6 +306,46 @@ namespace System { return page; } + auto ServiceMenu::buildOptionsPage() const -> Page { + auto on_off_text = [](bool v) -> std::string { + return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); + }; + + DebugOverlay* debug = debug_overlay_; + + Page page; + page.title_key = "service_menu.options"; + page.items = { + // IDIOMA (cycle entre ca i en, mateix codi que F7). + Item{ + .kind = Kind::CYCLE, + .label_key = "service_menu.options_language", + .selectable = true, + .on_activate = {}, + .get_value_text = [] { return Locale::get().text("language." + ConfigYaml::engine_config.locale); }, + .on_change = [](int) { + const std::string NEW_LANG = (ConfigYaml::engine_config.locale == "ca") ? "en" : "ca"; + if (Locale::get().switchTo(NEW_LANG)) { + ConfigYaml::engine_config.locale = NEW_LANG; + ConfigYaml::saveToFile(); + } }, + }, + // MOSTRAR INFO (debug overlay, equivalent a F11). + Item{ + .kind = Kind::TOGGLE, + .label_key = "service_menu.options_show_info", + .selectable = true, + .on_activate = {}, + .get_value_text = [debug, on_off_text] { return on_off_text(debug != nullptr && debug->isVisible()); }, + .on_change = [debug](int) { + if (debug != nullptr) { + debug->toggle(); + } }, + }, + }; + return page; + } + void ServiceMenu::pushPage(Page page) { stack_.push_back(std::move(page)); // El cursor salta a una pagina nova: enganxem el highlight per a diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index 24210cc..3c8d128 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -32,6 +32,8 @@ class SDLManager; namespace System { + class DebugOverlay; + class ServiceMenu { public: // Tipus d'item de menu. En aquesta iteracio nomes s'usen SUBMENU i @@ -64,10 +66,10 @@ namespace System { std::size_t cursor = 0; }; - // Inicialitza el singleton amb el renderer global i l'SDLManager (per - // a operar amb video toggles: fullscreen, vsync, AA, postfx, zoom). - // Tots dos son propietat del Director i sobreviuen al menu. - static void init(Rendering::Renderer* renderer, SDLManager* sdl); + // Inicialitza el singleton amb el renderer global, l'SDLManager (video + // toggles: fullscreen, vsync, AA, postfx, zoom) i el DebugOverlay + // (toggle del HUD de debug a OPCIONS). Tots propietat del Director. + static void init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay); static void destroy(); [[nodiscard]] static auto get() -> ServiceMenu*; @@ -83,12 +85,13 @@ namespace System { auto handleEvent(const SDL_Event& event) -> bool; private: - ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl); + ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay); void buildRootPage(); void pushSubmenuPlaceholder(const std::string& title_key); [[nodiscard]] auto buildVideoPage() const -> Page; [[nodiscard]] static auto buildAudioPage() -> Page; + [[nodiscard]] auto buildOptionsPage() const -> Page; void pushPage(Page page); void popPage(); void moveCursor(int direction); @@ -105,6 +108,7 @@ namespace System { Rendering::Renderer* renderer_; SDLManager* sdl_; + DebugOverlay* debug_overlay_; Graphics::VectorText text_; std::vector stack_; From 22827c28fa86cf607627bcd79f00ceb55feb6900 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 12:18:39 +0200 Subject: [PATCH 5/6] feat(service-menu): pobla SISTEMA amb reinici, eixir i confirmacions --- data/locale/ca.yaml | 7 + data/locale/en.yaml | 7 + source/core/defaults/service_menu.hpp | 21 ++- source/core/system/relaunch.cpp | 61 +++++++ source/core/system/relaunch.hpp | 33 ++++ source/core/system/service_menu.cpp | 220 ++++++++++++++++++++++---- source/core/system/service_menu.hpp | 19 ++- source/main.cpp | 8 + 8 files changed, 338 insertions(+), 38 deletions(-) create mode 100644 source/core/system/relaunch.cpp create mode 100644 source/core/system/relaunch.hpp diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index a49f63b..737566c 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -71,6 +71,13 @@ service_menu: audio_music_volume: "VOLUM MUSICA" audio_sound: "EFECTES" audio_sound_volume: "VOLUM EFECTES" + # Items del submenu SISTEMA + system_restart: "REINICIAR" + # Pagines de confirmacio (estructura: titol + NO/SI) + confirm_restart: "ESTAS SEGUR DE REINICIAR?" + confirm_exit: "ESTAS SEGUR DE EIXIR?" + confirm_no: "NO" + confirm_yes: "SI" # Valors comuns value_on: "ACTIU" value_off: "INACTIU" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index a10340e..73a4b0b 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -70,6 +70,13 @@ service_menu: audio_music_volume: "MUSIC VOLUME" audio_sound: "SOUNDS" audio_sound_volume: "SOUND VOLUME" + # Items of SYSTEM submenu + system_restart: "RESTART" + # Confirmation pages (structure: title + NO/YES) + confirm_restart: "REALLY RESTART?" + confirm_exit: "REALLY EXIT?" + confirm_no: "NO" + confirm_yes: "YES" # Common values value_on: "ON" value_off: "OFF" diff --git a/source/core/defaults/service_menu.hpp b/source/core/defaults/service_menu.hpp index 72e02c8..cc3614c 100644 --- a/source/core/defaults/service_menu.hpp +++ b/source/core/defaults/service_menu.hpp @@ -8,9 +8,12 @@ namespace Defaults::ServiceMenu { // ---- Mides en coordenades logiques del joc (1280×720) ---- - constexpr int BOX_WIDTH = 460; + // BOX_WIDTH_MIN es el minim: si el titol o algun item no hi caben, el + // marc s'expandeix dinamicament amb animacio (cf. WIDTH_RATE). + constexpr int BOX_WIDTH_MIN = 460; constexpr int GAP_Y = 22; - constexpr int TITLE_HEIGHT = 36; // scale 0.85 ≈ 34 px de text + constexpr int TITLE_HEIGHT = 36; // scale 0.85 ≈ 34 px de text + constexpr int SUBTITLE_HEIGHT = 18; // scale 0.4 ≈ 16 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; @@ -24,20 +27,23 @@ namespace Defaults::ServiceMenu { 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 + constexpr float WIDTH_RATE = 12.0F; // smoothing per a canvis d'ample entre pagines // ---- 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 - constexpr int TEXT_INSET_X = 16; // marge intern del text dins del highlight (label esq / valor dre) + constexpr int HIGHLIGHT_PAD_X = 18; // padding lateral del rect respecte al text + constexpr int HIGHLIGHT_PAD_Y = 4; // padding vertical + constexpr int TEXT_INSET_X = 16; // marge intern del text dins del highlight (label esq / valor dre) + constexpr int MIN_LABEL_VALUE_GAP = 30; // mínim gap entre label i valor (per al càlcul d'ample dinàmic) // ---- 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 SUBTITLE_COLOR{.r = 110, .g = 170, .b = 210, .a = 220}; // cian apagat 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 @@ -45,8 +51,9 @@ namespace Defaults::ServiceMenu { 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 TITLE_SCALE = 0.85F; // mateixa escala que el HUD del scoreboard + constexpr float SUBTITLE_SCALE = 0.40F; // sota el titol, info decorativa (versio/hash) + 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 ---- diff --git a/source/core/system/relaunch.cpp b/source/core/system/relaunch.cpp new file mode 100644 index 0000000..a6e13ed --- /dev/null +++ b/source/core/system/relaunch.cpp @@ -0,0 +1,61 @@ +// relaunch.cpp - Implementacio del reinici en calent +// © 2026 JailDesigner + +#include "core/system/relaunch.hpp" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include // _execv +#else +#include // execv +#endif + +namespace { + + // Estat global (process-scope). Aquesta TU es la unica que gestiona el + // reinici, aixi que els static interns no s'escapen. + char** g_argv = nullptr; + bool g_requested = false; + +} // namespace + +namespace System::Relaunch { + + void setArgv(int /*argc*/, char** argv) { + g_argv = argv; + } + + void request() { + g_requested = true; + } + + auto isRequested() -> bool { + return g_requested; + } + + void execIfRequested() { +#ifdef __EMSCRIPTEN__ + // Al navegador el reinici real seria location.reload(); aqui no fem res. + return; +#else + if (!g_requested || g_argv == nullptr || g_argv[0] == nullptr) { + return; + } + std::cout << "[Relaunch] Reiniciant " << g_argv[0] << "...\n"; +#ifdef _WIN32 + _execv(g_argv[0], g_argv); +#else + execv(g_argv[0], g_argv); +#endif + // Si arribem aqui, execv ha fallat. Tots els subsistemes ja estan + // destruits; sortim amb error i el shell rebra el codi. + std::cerr << "[Relaunch] Ha fallat: " << std::strerror(errno) << '\n'; + std::exit(EXIT_FAILURE); +#endif + } + +} // namespace System::Relaunch diff --git a/source/core/system/relaunch.hpp b/source/core/system/relaunch.hpp new file mode 100644 index 0000000..b3df0b1 --- /dev/null +++ b/source/core/system/relaunch.hpp @@ -0,0 +1,33 @@ +// relaunch.hpp - Reinici en calent del proces (execv) +// © 2026 JailDesigner +// +// Helper desacoblat per a permetre que el menu de servei demani un reinici +// sense conèixer Director ni main.cpp. Patro: +// +// main() → Relaunch::setArgv(argc, argv) (a l'arrencada) +// ServiceMenu → Relaunch::request() (en activar REINICIAR) +// main() → Relaunch::execIfRequested() (a SDL_AppQuit) +// +// L'execv() reemplaca el proces actual: si torna, ha fallat. A EMSCRIPTEN +// no es pot reiniciar; isRequested() seguira dient true pero execIfRequested +// sera no-op. + +#pragma once + +namespace System::Relaunch { + + // Emmagatzema l'argv original. Cal cridar-ho una vegada des de main. + void setArgv(int argc, char** argv); + + // Demana un reinici (no actua immediatament; nomes marca el flag). + void request(); + + // Consulta del flag. + [[nodiscard]] auto isRequested() -> bool; + + // Si hi ha reinici demanat i tenim argv valid, fa execv. En cas d'exit + // no torna. Si execv falla, registra l'error i torna; el caller hauria + // de sortir normalment. + void execIfRequested(); + +} // namespace System::Relaunch diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index 5e5cef6..7f78842 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -4,6 +4,7 @@ #include "core/system/service_menu.hpp" #include +#include #include #include #include @@ -16,8 +17,10 @@ #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" #include "core/system/debug_overlay.hpp" +#include "core/system/relaunch.hpp" #include "core/types.hpp" #include "game/config_yaml.hpp" +#include "project.h" namespace { @@ -50,6 +53,17 @@ namespace { } } + // VectorText nomes admet ASCII en majuscules. El git hash sortit de git + // rev-parse es lowercase (a-f), aixi que el passem a uppercase per al + // display sense modificar Project::GIT_HASH. + auto toUpperAscii(const std::string& s) -> std::string { + std::string result = s; + for (char& c : result) { + c = static_cast(std::toupper(static_cast(c))); + } + return result; + } + } // namespace namespace System { @@ -86,6 +100,10 @@ namespace System { animated_h_ = 0.0F; highlight_snap_ = true; // primera frame: enganxar el highlight al cursor buildRootPage(); + // L'ample comença ja al valor objectiu (la caixa surt amb l'amplada + // final i nomes anima l'alçada). L'ample s'animarà despres entre + // pagines (push/pop). + animated_w_ = computeTargetWidth(); playAcceptSound(); return; } @@ -121,20 +139,12 @@ namespace System { makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }), makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }), makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }), - makeSubmenu("service_menu.system", [this] { pushSubmenuPlaceholder("service_menu.system"); }), + makeSubmenu("service_menu.system", [this] { pushPage(buildSystemPage()); }), }; 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)); - } - auto ServiceMenu::buildVideoPage() const -> Page { // Helper: localitza ON/OFF per a TOGGLE items. auto on_off_text = [](bool v) -> std::string { @@ -346,6 +356,76 @@ namespace System { return page; } + auto ServiceMenu::buildSystemPage() -> Page { + Page page; + page.title_key = "service_menu.system"; + // Versio + hash com a subtitol sota el titol (apagat, mes petit). + // Uppercase del hash perque VectorText nomes admet majuscules. + page.subtitle_provider = [] { + return std::format("V{} - {}", Project::VERSION, toUpperAscii(Project::GIT_HASH)); + }; + page.items = { + // REINICIAR (amb confirmacio). + Item{ + .kind = Kind::ACTION, + .label_key = "service_menu.system_restart", + .selectable = true, + .on_activate = [this] { + pushConfirmPage("service_menu.confirm_restart", [] { + System::Relaunch::request(); + SDL_Event quit_event{}; + quit_event.type = SDL_EVENT_QUIT; + SDL_PushEvent(&quit_event); + }); + }, + .get_value_text = {}, + .on_change = {}, + }, + // EIXIR DEL JOC (amb confirmacio). + Item{ + .kind = Kind::ACTION, + .label_key = "service_menu.exit", + .selectable = true, + .on_activate = [this] { + pushConfirmPage("service_menu.confirm_exit", [] { + SDL_Event quit_event{}; + quit_event.type = SDL_EVENT_QUIT; + SDL_PushEvent(&quit_event); + }); + }, + .get_value_text = {}, + .on_change = {}, + }, + }; + return page; + } + + void ServiceMenu::pushConfirmPage(const std::string& title_key, std::function on_yes) { + auto yes_callback = std::move(on_yes); + Page page; + page.title_key = title_key; + page.cursor = 0; // per defecte sobre NO (segur) + page.items = { + Item{ + .kind = Kind::ACTION, + .label_key = "service_menu.confirm_no", + .selectable = true, + .on_activate = [this] { popPage(); }, + .get_value_text = {}, + .on_change = {}, + }, + Item{ + .kind = Kind::ACTION, + .label_key = "service_menu.confirm_yes", + .selectable = true, + .on_activate = std::move(yes_callback), + .get_value_text = {}, + .on_change = {}, + }, + }; + 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 @@ -463,8 +543,12 @@ namespace System { } using namespace Defaults::ServiceMenu; const Page& page = stack_.back(); - int h = GAP_Y; // padding superior - h += TITLE_HEIGHT + GAP_Y; // titol + gap + int h = GAP_Y; // padding superior + h += TITLE_HEIGHT; // titol + if (page.subtitle_provider) { + h += GAP_Y / 2 + SUBTITLE_HEIGHT; // subtitol amb mig gap + } + h += GAP_Y; // gap abans del separador h += SEPARATOR_HEIGHT + GAP_Y; // separador + gap const auto N = static_cast(page.items.size()); if (N > 0) { @@ -473,15 +557,58 @@ namespace System { return static_cast(h); } - auto ServiceMenu::computeItemTopY(float box_y, std::size_t index) -> float { + auto ServiceMenu::computeTargetWidth() const -> float { using namespace Defaults::ServiceMenu; - const float ITEMS_Y0 = box_y + - static_cast(GAP_Y) + - static_cast(TITLE_HEIGHT) + + if (stack_.empty()) { + return static_cast(BOX_WIDTH_MIN); + } + const Page& page = stack_.back(); + // Comencem amb l'ample del titol. + float content_w = Graphics::VectorText::getTextWidth( + Locale::get().text(page.title_key), + TITLE_SCALE, + TEXT_SPACING); + if (page.subtitle_provider) { + content_w = std::max(content_w, Graphics::VectorText::getTextWidth(page.subtitle_provider(), SUBTITLE_SCALE, TEXT_SPACING)); + } + for (const Item& item : page.items) { + const std::string LABEL = item.label_key.empty() + ? std::string{} + : Locale::get().text(item.label_key); + if (item.label_key.empty() && item.get_value_text) { + content_w = std::max(content_w, Graphics::VectorText::getTextWidth(item.get_value_text(), ITEM_SCALE, TEXT_SPACING)); + } else if (item.get_value_text) { + const float LABEL_W = Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING); + const float VALUE_W = Graphics::VectorText::getTextWidth( + item.get_value_text(), + ITEM_SCALE, + TEXT_SPACING); + content_w = std::max(content_w, + LABEL_W + static_cast(MIN_LABEL_VALUE_GAP) + VALUE_W); + } else { + content_w = std::max(content_w, + Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING)); + } + } + // Padding total: highlight pad als dos costats + inset del text. + const float REQUIRED = content_w + + (2.0F * static_cast(HIGHLIGHT_PAD_X)) + + (2.0F * static_cast(TEXT_INSET_X)); + return std::max(static_cast(BOX_WIDTH_MIN), REQUIRED); + } + + auto ServiceMenu::computeItemTopY(float box_y, std::size_t index, bool has_subtitle) -> float { + using namespace Defaults::ServiceMenu; + float items_y0 = box_y + static_cast(GAP_Y) + + static_cast(TITLE_HEIGHT); + if (has_subtitle) { + items_y0 += static_cast(GAP_Y / 2) + static_cast(SUBTITLE_HEIGHT); + } + items_y0 += 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)); + return items_y0 + (static_cast(index) * static_cast(ITEM_HEIGHT + ITEM_GAP_Y)); } void ServiceMenu::update(float delta_time) { @@ -504,10 +631,13 @@ namespace System { 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; + // Smoothing exponencial cap a l'alçada i ample objectius de la pagina. + const float TARGET_H_BOX = closing_ ? 0.0F : computeTargetHeight(); + const float TARGET_W_BOX = closing_ ? animated_w_ : computeTargetWidth(); + const float ALPHA_H = 1.0F - std::exp(-HEIGHT_RATE * delta_time); + const float ALPHA_W = 1.0F - std::exp(-WIDTH_RATE * delta_time); + animated_h_ += (TARGET_H_BOX - animated_h_) * ALPHA_H; + animated_w_ += (TARGET_W_BOX - animated_w_) * ALPHA_W; // 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 @@ -522,7 +652,8 @@ namespace System { } 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 bool HAS_SUBTITLE = static_cast(page.subtitle_provider); + const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor, HAS_SUBTITLE); 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_) { @@ -603,9 +734,9 @@ namespace System { return; } - const float BOX_X = (CANVAS_W - static_cast(BOX_WIDTH)) * 0.5F; + const float BOX_W = animated_w_; + const float BOX_X = (CANVAS_W - BOX_W) * 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. @@ -623,6 +754,7 @@ namespace System { renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H); const Page& page = stack_.back(); + const bool HAS_SUBTITLE = static_cast(page.subtitle_provider); // Titol centrat al cim de la caixa. const std::string TITLE = Locale::get().text(page.title_key); @@ -634,8 +766,27 @@ namespace System { 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); + // Subtitol opcional: sota el titol, mes petit i apagat. + if (HAS_SUBTITLE) { + const float SUBTITLE_CY = BOX_Y + static_cast(GAP_Y) + + static_cast(TITLE_HEIGHT) + + (static_cast(GAP_Y) / 4.0F) + + (static_cast(SUBTITLE_HEIGHT) * 0.5F); + text_.renderCentered(page.subtitle_provider(), + Vec2{.x = CENTER_X, .y = SUBTITLE_CY}, + SUBTITLE_SCALE, + TEXT_SPACING, + 1.0F, + SUBTITLE_COLOR); + } + + // Separador horitzontal sota el titol (o subtitol si n'hi ha). + float sep_y = BOX_Y + static_cast(GAP_Y) + static_cast(TITLE_HEIGHT); + if (HAS_SUBTITLE) { + sep_y += static_cast(GAP_Y / 2) + static_cast(SUBTITLE_HEIGHT); + } + sep_y += static_cast(GAP_Y) * 0.5F; + const float SEP_Y = sep_y; fillRect(renderer_, BOX_X + static_cast(GAP_Y), SEP_Y, @@ -659,11 +810,24 @@ namespace System { 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 LABEL = Locale::get().text(item.label_key); - const float ITEM_TOP = computeItemTopY(BOX_Y, i); + // Salta el Locale lookup si label_key esta buit (item nomes-valor). + const std::string LABEL = item.label_key.empty() + ? std::string{} + : Locale::get().text(item.label_key); + const float ITEM_TOP = computeItemTopY(BOX_Y, i, HAS_SUBTITLE); const float ITEM_CY = ITEM_TOP + (static_cast(ITEM_HEIGHT) * 0.5F); - if (item.get_value_text) { + if (item.label_key.empty() && item.get_value_text) { + // Item nomes-valor (sense label_key): el text del valor es + // renderitza centrat com a label decoratiu. Util per a items + // d'informacio com la versio/hash a SISTEMA. + text_.renderCentered(item.get_value_text(), + Vec2{.x = CENTER_X, .y = ITEM_CY}, + ITEM_SCALE, + TEXT_SPACING, + 1.0F, + COL); + } else if (item.get_value_text) { // Layout dues columnes: label esquerra, valor dreta. const std::string VALUE = item.get_value_text(); const float TEXT_TOP_Y = ITEM_CY - TEXT_TOP_OFFSET; diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index 3c8d128..0b385f9 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -62,6 +62,10 @@ namespace System { struct Page { std::string title_key; + // Subtitol opcional, renderitzat sota el titol amb tipografia mes + // petita i color apagat. Es una funcio perque pot ser dinamic + // (versio+hash, etc.). Si esta buit, no es renderitza. + std::function subtitle_provider; std::vector items; std::size_t cursor = 0; }; @@ -88,10 +92,13 @@ namespace System { ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay); void buildRootPage(); - void pushSubmenuPlaceholder(const std::string& title_key); [[nodiscard]] auto buildVideoPage() const -> Page; [[nodiscard]] static auto buildAudioPage() -> Page; [[nodiscard]] auto buildOptionsPage() const -> Page; + [[nodiscard]] auto buildSystemPage() -> Page; + // Pagina de confirmacio "ESTAS SEGUR? NO/SI". on_yes s'executa si + // l'usuari selecciona SI; el cursor per defecte apunta a NO. + void pushConfirmPage(const std::string& title_key, std::function on_yes); void pushPage(Page page); void popPage(); void moveCursor(int direction); @@ -103,8 +110,13 @@ namespace System { // 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; + // Ample objectiu de la caixa per a la pagina superior (sense animacio). + // Pren com a base BOX_WIDTH_MIN i s'eixampla si algun text no hi cap. + [[nodiscard]] auto computeTargetWidth() const -> float; + + // Y (top) de l'item index dins una caixa col·locada a box_y. Si la + // pagina te subtitol, els items es desplacen cap avall. + [[nodiscard]] static auto computeItemTopY(float box_y, std::size_t index, bool has_subtitle) -> float; Rendering::Renderer* renderer_; SDLManager* sdl_; @@ -116,6 +128,7 @@ namespace System { bool closing_ = false; float open_anim_ = 0.0F; // 0..1 raw (sense easing) float animated_h_ = 0.0F; // Alçada animada amb smoothing exponencial + float animated_w_ = 0.0F; // Ample animat (eixampla segons contingut) // Estat del highlight (rectangle del cursor). Es lerpa cap a l'item // actiu amb ease-out exponencial; quan el cursor "salta" (open o diff --git a/source/main.cpp b/source/main.cpp index c03c04d..3cb4764 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -13,8 +13,12 @@ #include #include "core/system/director.hpp" +#include "core/system/relaunch.hpp" auto SDL_AppInit(void** appstate, int argc, char* argv[]) -> SDL_AppResult { + // Desem argv perquè el menu de servei pugui demanar un reinici en calent + // (execv) sense haver de conèixer Director. + System::Relaunch::setArgv(argc, argv); auto director = std::make_unique(argc, argv); *appstate = director.release(); return SDL_APP_CONTINUE; @@ -33,4 +37,8 @@ auto SDL_AppIterate(void* appstate) -> SDL_AppResult { void SDL_AppQuit(void* appstate, SDL_AppResult /*result*/) { // Reabsorbim la propietat: el destructor del Director allibera tot. std::unique_ptr director(static_cast(appstate)); + director.reset(); + // Si el menu va demanar reinici, fem execv ara que tot esta net. En cas + // d'exit no torna; si falla, l'aplicacio surt amb codi d'error. + System::Relaunch::execIfRequested(); } From 7eafe216237b7697251c48fad67353aa44194dbb Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 24 May 2026 12:30:47 +0200 Subject: [PATCH 6/6] feat(service-menu): submenu RESOLUCIO amb canvi en calent de l'offscreen --- data/locale/ca.yaml | 1 + data/locale/en.yaml | 1 + source/core/rendering/sdl_manager.cpp | 20 ++++++ source/core/rendering/sdl_manager.hpp | 16 +++-- source/core/system/service_menu.cpp | 91 +++++++++++++++++++++++---- source/core/system/service_menu.hpp | 6 +- 6 files changed, 116 insertions(+), 19 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 737566c..71ccc66 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -61,6 +61,7 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESSAT" + video_resolution: "RESOLUCIO" # Items del submenu OPCIONS options_language: "IDIOMA" options_show_info: "MOSTRAR INFO" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 73a4b0b..f5fc757 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -60,6 +60,7 @@ service_menu: video_vsync: "VSYNC" video_aa: "ANTIALIAS" video_postfx: "POSTPROCESS" + video_resolution: "RESOLUTION" # Items of OPTIONS submenu options_language: "LANGUAGE" options_show_info: "SHOW INFO" diff --git a/source/core/rendering/sdl_manager.cpp b/source/core/rendering/sdl_manager.cpp index 9fddfae..7108e22 100644 --- a/source/core/rendering/sdl_manager.cpp +++ b/source/core/rendering/sdl_manager.cpp @@ -385,6 +385,26 @@ void SDLManager::toggleAntialias() { } } +void SDLManager::setRenderResolution(int w, int h) { + if (!Defaults::Rendering::isValidRenderResolution(w, h)) { + std::cerr << "[SDLManager] Resolucio no valida (" << w << "x" << h + << "), ignorant.\n"; + return; + } + if (w == cfg_->rendering.render_width && h == cfg_->rendering.render_height) { + return; // ja era l'actual + } + if (!gpu_renderer_.resizeRenderTarget(static_cast(w), static_cast(h))) { + std::cerr << "[SDLManager] resizeRenderTarget ha fallat.\n"; + return; + } + cfg_->rendering.render_width = w; + cfg_->rendering.render_height = h; + if (on_persist_) { + on_persist_(); + } +} + void SDLManager::togglePostFx() { const bool NEW_STATE = !gpu_renderer_.isPostFxEnabled(); gpu_renderer_.setPostFxEnabled(NEW_STATE); diff --git a/source/core/rendering/sdl_manager.hpp b/source/core/rendering/sdl_manager.hpp index 239d537..63c0f6f 100644 --- a/source/core/rendering/sdl_manager.hpp +++ b/source/core/rendering/sdl_manager.hpp @@ -30,12 +30,16 @@ class SDLManager { auto operator=(const SDLManager&) -> SDLManager& = delete; // [NUEVO] Gestió de finestra dinàmica - void increaseWindowSize(); // F2: +100px - void decreaseWindowSize(); // F1: -100px - void toggleFullscreen(); // F3 - void toggleVSync(); // F4 - void toggleAntialias(); // F5 - void togglePostFx(); // F6 + void increaseWindowSize(); // F2: +100px + void decreaseWindowSize(); // F1: -100px + void toggleFullscreen(); // F3 + void toggleVSync(); // F4 + void toggleAntialias(); // F5 + void togglePostFx(); // F6 + // Canvia la resolució del render target offscreen (recrea la textura). + // Cal cridar-lo fora d'un frame (event phase, no draw phase). Si el + // valor no es un preset valid o ja es l'actual, es no-op. + void setRenderResolution(int w, int h); auto handleWindowEvent(const SDL_Event& event) -> bool; // Per a SDL_EVENT_WINDOW_RESIZED // Funciones principals (renderizado). diff --git a/source/core/system/service_menu.cpp b/source/core/system/service_menu.cpp index 7f78842..67b026f 100644 --- a/source/core/system/service_menu.cpp +++ b/source/core/system/service_menu.cpp @@ -13,6 +13,7 @@ #include "core/audio/audio.hpp" #include "core/config/engine_config.hpp" #include "core/defaults/audio.hpp" +#include "core/defaults/rendering.hpp" #include "core/defaults/service_menu.hpp" #include "core/locale/locale.hpp" #include "core/rendering/sdl_manager.hpp" @@ -64,6 +65,18 @@ namespace { return result; } + // Resol el text del label d'un item: prioritza label_text (literal) sobre + // label_key (locale). Retorna cadena buida si tots dos son buits. + auto resolveLabel(const System::ServiceMenu::Item& item) -> std::string { + if (!item.label_text.empty()) { + return item.label_text; + } + if (item.label_key.empty()) { + return {}; + } + return Locale::get().text(item.label_key); + } + } // namespace namespace System { @@ -123,6 +136,7 @@ namespace System { return ServiceMenu::Item{ .kind = ServiceMenu::Kind::SUBMENU, .label_key = label_key, + .label_text = {}, .selectable = true, .on_activate = std::move(on_activate), .get_value_text = {}, @@ -145,7 +159,7 @@ namespace System { stack_.push_back(std::move(root)); } - auto ServiceMenu::buildVideoPage() const -> Page { + auto ServiceMenu::buildVideoPage() -> Page { // Helper: localitza ON/OFF per a TOGGLE items. auto on_off_text = [](bool v) -> std::string { return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); @@ -160,6 +174,7 @@ namespace System { Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.video_zoom", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [sdl] { return std::format("{:.1f}X", sdl->getScaleFactor()); }, @@ -174,6 +189,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_fullscreen", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isFullscreen()); }, @@ -183,15 +199,29 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_vsync", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.vsync != 0); }, .on_change = [sdl](int) { sdl->toggleVSync(); }, }, + // RESOLUCIO (sub-submenu amb els 5 presets; mostra l'actual com a valor) + Item{ + .kind = Kind::SUBMENU, + .label_key = "service_menu.video_resolution", + .label_text = {}, + .selectable = true, + .on_activate = [this] { pushPage(buildResolutionPage()); }, + .get_value_text = [] { return std::format("{}X{}", + ConfigYaml::engine_config.rendering.render_width, + ConfigYaml::engine_config.rendering.render_height); }, + .on_change = {}, + }, // ANTIALIAS Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_aa", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.antialias != 0); }, @@ -201,6 +231,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.video_postfx", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isPostFxEnabled()); }, @@ -210,6 +241,36 @@ namespace System { return page; } + auto ServiceMenu::buildResolutionPage() const -> Page { + Page page; + page.title_key = "service_menu.video_resolution"; + // El cursor arrenca sobre el preset actual perquè l'usuari vegi quin + // esta seleccionat sense buscar-lo. + const int CURR_W = ConfigYaml::engine_config.rendering.render_width; + const int CURR_H = ConfigYaml::engine_config.rendering.render_height; + std::size_t cursor = 0; + SDLManager* sdl = sdl_; + for (std::size_t i = 0; i < Defaults::Rendering::RESOLUTION_PRESETS.size(); ++i) { + const auto& preset = Defaults::Rendering::RESOLUTION_PRESETS[i]; + if (preset.w == CURR_W && preset.h == CURR_H) { + cursor = i; + } + const int PW = preset.w; + const int PH = preset.h; + page.items.push_back(Item{ + .kind = Kind::ACTION, + .label_key = {}, + .label_text = std::format("{}X{}", PW, PH), + .selectable = true, + .on_activate = [sdl, PW, PH] { sdl->setRenderResolution(PW, PH); }, + .get_value_text = {}, + .on_change = {}, + }); + } + page.cursor = cursor; + return page; + } + auto ServiceMenu::buildAudioPage() -> Page { auto on_off_text = [](bool v) -> std::string { return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off"); @@ -229,6 +290,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_master", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { @@ -243,6 +305,7 @@ namespace System { Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_master_volume", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { @@ -258,6 +321,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_music", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { @@ -272,6 +336,7 @@ namespace System { Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_music_volume", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { @@ -287,6 +352,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.audio_sound", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [on_off_text] { @@ -301,6 +367,7 @@ namespace System { Item{ .kind = Kind::INT_RANGE, .label_key = "service_menu.audio_sound_volume", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { @@ -330,6 +397,7 @@ namespace System { Item{ .kind = Kind::CYCLE, .label_key = "service_menu.options_language", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [] { return Locale::get().text("language." + ConfigYaml::engine_config.locale); }, @@ -344,6 +412,7 @@ namespace System { Item{ .kind = Kind::TOGGLE, .label_key = "service_menu.options_show_info", + .label_text = {}, .selectable = true, .on_activate = {}, .get_value_text = [debug, on_off_text] { return on_off_text(debug != nullptr && debug->isVisible()); }, @@ -369,6 +438,7 @@ namespace System { Item{ .kind = Kind::ACTION, .label_key = "service_menu.system_restart", + .label_text = {}, .selectable = true, .on_activate = [this] { pushConfirmPage("service_menu.confirm_restart", [] { @@ -385,6 +455,7 @@ namespace System { Item{ .kind = Kind::ACTION, .label_key = "service_menu.exit", + .label_text = {}, .selectable = true, .on_activate = [this] { pushConfirmPage("service_menu.confirm_exit", [] { @@ -409,6 +480,7 @@ namespace System { Item{ .kind = Kind::ACTION, .label_key = "service_menu.confirm_no", + .label_text = {}, .selectable = true, .on_activate = [this] { popPage(); }, .get_value_text = {}, @@ -417,6 +489,7 @@ namespace System { Item{ .kind = Kind::ACTION, .label_key = "service_menu.confirm_yes", + .label_text = {}, .selectable = true, .on_activate = std::move(yes_callback), .get_value_text = {}, @@ -572,10 +645,8 @@ namespace System { content_w = std::max(content_w, Graphics::VectorText::getTextWidth(page.subtitle_provider(), SUBTITLE_SCALE, TEXT_SPACING)); } for (const Item& item : page.items) { - const std::string LABEL = item.label_key.empty() - ? std::string{} - : Locale::get().text(item.label_key); - if (item.label_key.empty() && item.get_value_text) { + const std::string LABEL = resolveLabel(item); + if (LABEL.empty() && item.get_value_text) { content_w = std::max(content_w, Graphics::VectorText::getTextWidth(item.get_value_text(), ITEM_SCALE, TEXT_SPACING)); } else if (item.get_value_text) { const float LABEL_W = Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING); @@ -810,15 +881,13 @@ namespace System { 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; - // Salta el Locale lookup si label_key esta buit (item nomes-valor). - const std::string LABEL = item.label_key.empty() - ? std::string{} - : Locale::get().text(item.label_key); + // resolveLabel prioritza label_text (literal) sobre label_key (locale). + const std::string LABEL = resolveLabel(item); const float ITEM_TOP = computeItemTopY(BOX_Y, i, HAS_SUBTITLE); const float ITEM_CY = ITEM_TOP + (static_cast(ITEM_HEIGHT) * 0.5F); - if (item.label_key.empty() && item.get_value_text) { - // Item nomes-valor (sense label_key): el text del valor es + if (LABEL.empty() && item.get_value_text) { + // Item nomes-valor (sense label): el text del valor es // renderitza centrat com a label decoratiu. Util per a items // d'informacio com la versio/hash a SISTEMA. text_.renderCentered(item.get_value_text(), diff --git a/source/core/system/service_menu.hpp b/source/core/system/service_menu.hpp index 0b385f9..8b4b732 100644 --- a/source/core/system/service_menu.hpp +++ b/source/core/system/service_menu.hpp @@ -50,7 +50,8 @@ namespace System { struct Item { Kind kind = Kind::LABEL; - std::string label_key; // Clau de locale + std::string label_key; // Clau de locale (s'ignora si label_text no esta buit) + std::string label_text; // Text literal (no locale). Util per a labels que no necessiten traduccio (resolucions, etc.) bool selectable = true; // SUBMENU / ACTION: callback en ENTER / RIGHT. std::function on_activate; @@ -92,7 +93,8 @@ namespace System { ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay); void buildRootPage(); - [[nodiscard]] auto buildVideoPage() const -> Page; + [[nodiscard]] auto buildVideoPage() -> Page; + [[nodiscard]] auto buildResolutionPage() const -> Page; [[nodiscard]] static auto buildAudioPage() -> Page; [[nodiscard]] auto buildOptionsPage() const -> Page; [[nodiscard]] auto buildSystemPage() -> Page;