From 350625dec02effde08dba1bcb7077d126dab58a8 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 18:15:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(menu):=20primera=20versi=C3=B3=20del=20men?= =?UTF-8?q?=C3=BA=20de=20servei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 + data/input/keys.yaml | 3 + data/locale/ca.yaml | 40 ++ data/locale/en.yaml | 40 ++ source/core/input/global_inputs.cpp | 14 +- source/core/rendering/canvas.cpp | 43 ++ source/core/rendering/canvas.hpp | 24 + source/core/rendering/screen.cpp | 52 +- source/core/rendering/screen.hpp | 17 + source/core/rendering/surface.cpp | 8 +- source/core/rendering/text.hpp | 10 + source/core/system/director.cpp | 55 ++ source/core/system/director.hpp | 1 + source/game/options.cpp | 24 + source/game/options.hpp | 6 + source/game/options_apply.cpp | 21 + source/game/options_apply.hpp | 20 + source/game/ui/service_menu.cpp | 861 ++++++++++++++++++++++++++++ source/game/ui/service_menu.hpp | 216 +++++++ source/game/ui/ui_palette.hpp | 8 + source/utils/defaults.hpp | 28 + 21 files changed, 1477 insertions(+), 16 deletions(-) create mode 100644 source/game/options_apply.cpp create mode 100644 source/game/options_apply.hpp create mode 100644 source/game/ui/service_menu.cpp create mode 100644 source/game/ui/service_menu.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5468827..4769ac5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,8 @@ set(APP_SOURCES source/utils/utils.cpp source/game/debug_config.cpp source/game/options.cpp + source/game/options_apply.cpp + source/game/ui/service_menu.cpp source/game/scenes/placeholder_input.cpp source/game/scenes/logo_scene.cpp source/game/scenes/title_scene.cpp diff --git a/data/input/keys.yaml b/data/input/keys.yaml index 421d48e..ab85f94 100644 --- a/data/input/keys.yaml +++ b/data/input/keys.yaml @@ -29,6 +29,9 @@ keys: - id: toggle_vsync code: "F8" desc: "Activa / desactiva VSync" + - id: menu_toggle + code: "F9" + desc: "Obri / tanca el menú de servei" - id: toggle_render_info code: "F12" desc: "Cicle de l'overlay de debug: apagat / dalt / baix" diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index db2d5c7..135c9e5 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -20,3 +20,43 @@ input: scaling_overscan: "escalat: overscan" scaling_integer: "escalat: enter" press_again_exit: "prem una altra volta per eixir" + +menu: + titles: + root: "Menú de servei" + video: "Vídeo" + audio: "Àudio" + system: "Sistema" + items: + video_submenu: "Vídeo" + audio_submenu: "Àudio" + system_submenu: "Sistema" + zoom: "Zoom" + screen: "Pantalla completa" + vsync: "VSync" + scaling_mode: "Escalat" + shader: "Shader" + shader_type: "Tipus de shader" + preset: "Preset" + render_info: "Info de render" + uptime: "Mostra temps" + master_enable: "Àudio" + master_volume: "Volum general" + music: "Música" + music_volume: "Volum música" + sounds: "Sons" + sounds_volume: "Volum sons" + restart: "Reiniciar" + exit_game: "Eixir del joc" + values: + on: "Actiu" + off: "Inactiu" + submenu_arrow: ">" + scaling_disabled: "Sense" + scaling_stretch: "Stretch" + scaling_letterbox: "Letterbox" + scaling_overscan: "Overscan" + scaling_integer: "Enter" + render_info_off: "Apagat" + render_info_top: "Dalt" + render_info_bottom: "Baix" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index bd101f9..5babd3d 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -20,3 +20,43 @@ input: scaling_overscan: "scaling: overscan" scaling_integer: "scaling: integer" press_again_exit: "press again to exit" + +menu: + titles: + root: "Service menu" + video: "Video" + audio: "Audio" + system: "System" + items: + video_submenu: "Video" + audio_submenu: "Audio" + system_submenu: "System" + zoom: "Zoom" + screen: "Fullscreen" + vsync: "VSync" + scaling_mode: "Scaling" + shader: "Shader" + shader_type: "Shader type" + preset: "Preset" + render_info: "Render info" + uptime: "Show time" + master_enable: "Audio" + master_volume: "Master volume" + music: "Music" + music_volume: "Music volume" + sounds: "Sounds" + sounds_volume: "Sound volume" + restart: "Restart" + exit_game: "Exit game" + values: + on: "On" + off: "Off" + submenu_arrow: ">" + scaling_disabled: "Off" + scaling_stretch: "Stretch" + scaling_letterbox: "Letterbox" + scaling_overscan: "Overscan" + scaling_integer: "Integer" + render_info_off: "Off" + render_info_top: "Top" + render_info_bottom: "Bottom" diff --git a/source/core/input/global_inputs.cpp b/source/core/input/global_inputs.cpp index 42fd0d8..1178160 100644 --- a/source/core/input/global_inputs.cpp +++ b/source/core/input/global_inputs.cpp @@ -13,6 +13,7 @@ #include "core/rendering/screen.hpp" #include "core/system/event_buffer.hpp" #include "game/options.hpp" +#include "game/options_apply.hpp" #include "utils/defaults.hpp" namespace GlobalInputs { @@ -30,13 +31,6 @@ namespace GlobalInputs { float exit_window_s = 0.0F; bool quit_requested = false; - // Canvia uniforms i shader actiu sense recrear la finestra. - void reapplyShader() { - Screen::get().setActiveShader(Options::buildActiveShader()); - Screen::get().applyPostFXParams(Options::buildPostFXParams()); - Screen::get().applyCrtPiParams(Options::buildCrtPiParams()); - } - void notifyLocale(const std::string& key) { Notification::notify(Locale::get().text(key)); } @@ -76,14 +70,14 @@ namespace GlobalInputs { void onToggleShader() { const bool NEW_ENABLED = !Options::video().shader.enabled; Options::setShaderEnabled(NEW_ENABLED); - reapplyShader(); + OptionsApply::reapplyShader(); notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off"); } void onNextShader() { const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi"; Options::setActiveShaderName(NEW_NAME); - reapplyShader(); + OptionsApply::reapplyShader(); Notification::notify(Locale::get().text("input.shader") + ": " + NEW_NAME); } @@ -111,7 +105,7 @@ namespace GlobalInputs { new_preset = presets[idx].name; Options::setPostFXPreset(new_preset); } - reapplyShader(); + OptionsApply::reapplyShader(); Notification::notify(Locale::get().text("input.preset") + ": " + new_preset); } diff --git a/source/core/rendering/canvas.cpp b/source/core/rendering/canvas.cpp index 008349a..03a8767 100644 --- a/source/core/rendering/canvas.cpp +++ b/source/core/rendering/canvas.cpp @@ -1,9 +1,24 @@ #include "core/rendering/canvas.hpp" +#include +#include + #include "core/rendering/screen.hpp" namespace Rendering { + namespace { + + // Pila de clips en un namespace anònim perquè no siga accessible des + // de fora. L'app és single-threaded; si algun dia hi ha threads de + // render, s'ha de fer thread_local o passar-la explícitament. + auto clipStack() -> std::vector& { + static std::vector stack_; + return stack_; + } + + } // namespace + auto activeCanvas() -> Canvas { auto& screen = Screen::get(); return { @@ -13,4 +28,32 @@ namespace Rendering { }; } + auto activeClip() -> Clip { + const auto& stack = clipStack(); + if (!stack.empty()) { return stack.back(); } + const auto CANVAS = activeCanvas(); + return {.x = 0, .y = 0, .w = CANVAS.width, .h = CANVAS.height}; + } + + void pushClip(Clip rect) { + // Retall contra el canvas per garantir que els blitters no s'han + // de preocupar de coordenades negatives o més grans que el canvas. + const auto CANVAS = activeCanvas(); + const int X0 = std::max(rect.x, 0); + const int Y0 = std::max(rect.y, 0); + const int X1 = std::min(rect.x + rect.w, CANVAS.width); + const int Y1 = std::min(rect.y + rect.h, CANVAS.height); + clipStack().push_back({ + .x = X0, + .y = Y0, + .w = std::max(0, X1 - X0), + .h = std::max(0, Y1 - Y0), + }); + } + + void popClip() { + auto& stack = clipStack(); + if (!stack.empty()) { stack.pop_back(); } + } + } // namespace Rendering diff --git a/source/core/rendering/canvas.hpp b/source/core/rendering/canvas.hpp index 43ea158..df37bf1 100644 --- a/source/core/rendering/canvas.hpp +++ b/source/core/rendering/canvas.hpp @@ -20,9 +20,33 @@ namespace Rendering { int height{0}; }; + // Rectangle de clip (en píxels de canvas). Els blitters han de comprovar + // `clip` abans d'escriure cada píxel; fora del rectangle no es dibuixa. + // `push/pop` permet niar clips (tot i que de moment només en tenim un + // nivell d'ús, el menú). Si la pila és buida, `activeClip` retorna el + // canvas complet — comportament "sense clip". + struct Clip { + int x{0}; + int y{0}; + int w{0}; + int h{0}; + }; + // Retorna el canvas actiu del joc. En aquesta fase del projecte n'hi ha // sempre un i només un (el de Screen); en el futur, si cal renderitzar // off-screen (miniatures, efectes), es podrà pushejar/popar per ací. [[nodiscard]] auto activeCanvas() -> Canvas; + // Clip actiu. Si ningú ha fet push, és tot el canvas. + [[nodiscard]] auto activeClip() -> Clip; + + // Restringeix el dibuix al rectangle donat fins al proper popClip(). + // El rectangle es retalla contra el canvas (cap clip pot excedir els + // seus límits, així els blitters poden assumir-los sans). + void pushClip(Clip rect); + + // Desapila el clip actiu i restaura l'anterior. Si no hi havia cap + // push previ, l'operació és idempotent i queda a "canvas complet". + void popClip(); + } // namespace Rendering diff --git a/source/core/rendering/screen.cpp b/source/core/rendering/screen.cpp index a870b12..69248fb 100644 --- a/source/core/rendering/screen.cpp +++ b/source/core/rendering/screen.cpp @@ -220,17 +220,61 @@ void Screen::clear(std::uint8_t r, std::uint8_t g, std::uint8_t b) { } void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) { - const int X0 = std::max(x, 0); - const int Y0 = std::max(y, 0); - const int X1 = std::min(x + w, canvas_width_); - const int Y1 = std::min(y + h, canvas_height_); + const auto CLIP = Rendering::activeClip(); + const int X0 = std::max(x, CLIP.x); + const int Y0 = std::max(y, CLIP.y); + const int X1 = std::min(x + w, CLIP.x + CLIP.w); + const int Y1 = std::min(y + h, CLIP.y + CLIP.h); for (int iy = Y0; iy < Y1; ++iy) { std::uint32_t* row = canvas_.data() + (static_cast(iy) * canvas_width_); std::fill(row + X0, row + X1, argb); } } +void Screen::blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha) { + if (alpha == 0) { return; } + if (alpha == 0xFF) { + fillRect(x, y, w, h, argb); + return; + } + // Mescla linealment src (argb, alpha) sobre dst (el canvas). El canvas + // és ARGB amb R/G/B als bytes baixos i A fix a 0xFF; fem la mescla per + // canal i deixem A intacte perquè el backend espera opacitat total. + const std::uint8_t SA = alpha; + const std::uint8_t INV = 255 - alpha; + const std::uint8_t SR = (argb >> 16) & 0xFF; + const std::uint8_t SG = (argb >> 8) & 0xFF; + const std::uint8_t SB = argb & 0xFF; + const auto CLIP = Rendering::activeClip(); + const int X0 = std::max(x, CLIP.x); + const int Y0 = std::max(y, CLIP.y); + const int X1 = std::min(x + w, CLIP.x + CLIP.w); + const int Y1 = std::min(y + h, CLIP.y + CLIP.h); + for (int iy = Y0; iy < Y1; ++iy) { + std::uint32_t* row = canvas_.data() + (static_cast(iy) * canvas_width_); + for (int ix = X0; ix < X1; ++ix) { + const std::uint32_t DST = row[ix]; + const std::uint8_t DR = (DST >> 16) & 0xFF; + const std::uint8_t DG = (DST >> 8) & 0xFF; + const std::uint8_t DB = DST & 0xFF; + const std::uint8_t R = ((SR * SA) + (DR * INV)) / 255; + const std::uint8_t G = ((SG * SA) + (DG * INV)) / 255; + const std::uint8_t B = ((SB * SA) + (DB * INV)) / 255; + row[ix] = 0xFF000000U | (static_cast(R) << 16) | (static_cast(G) << 8) | static_cast(B); + } + } +} + +void Screen::setOverlayRenderer(std::function callback) { + overlay_renderer_ = std::move(callback); +} + void Screen::present() { + // Overlays (menú de servei, etc.) s'han de pintar al canvas ABANS que + // shader_backend llegisca els píxels per pujar-los a GPU; si no, queden + // per sota del postprocés o no apareixen al frame presentat. + if (overlay_renderer_) { overlay_renderer_(); } + fps_.increment(); fps_.calculate(SDL_GetTicks()); diff --git a/source/core/rendering/screen.hpp b/source/core/rendering/screen.hpp index d6ee052..763460a 100644 --- a/source/core/rendering/screen.hpp +++ b/source/core/rendering/screen.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -68,6 +69,16 @@ class Screen { // Pinta un rectangle opac al canvas ARGB del joc. Pensat per a HUDs // simples (caixes de notificacions, barres d'estat); no gasta paleta. void fillRect(int x, int y, int w, int h, std::uint32_t argb); + // Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival + // a fillRect. El color entra com a ARGB little-endian; `alpha` és el + // factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays + // (menú, pauses) que volen veure's a través el joc atenuat. + void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha); + // Hook invocat al principi de `present()`, abans de pujar els píxels al + // backend. Serveix per a pintar overlays que han de quedar per damunt + // de qualsevol escena (p.ex. el menú de servei). El Director el registra + // un sol cop; Screen no coneix el tipus concret del callback. + void setOverlayRenderer(std::function callback); void present(); [[nodiscard]] auto getRenderer() const -> SDL_Renderer* { return renderer_; } @@ -123,4 +134,10 @@ class Screen { FPS fps_{}; std::string gpu_driver_; + + // Callback cridat al principi de present() per pintar overlays per + // damunt de l'escena. Viu en Screen perquè la seqüència correcta + // (dibuix al canvas → overlay → upload al backend → present) queda + // centralitzada en un sol lloc, siga quina siga l'escena activa. + std::function overlay_renderer_; }; diff --git a/source/core/rendering/surface.cpp b/source/core/rendering/surface.cpp index e1a7da1..f0a12a6 100644 --- a/source/core/rendering/surface.cpp +++ b/source/core/rendering/surface.cpp @@ -90,9 +90,13 @@ void Surface::render(int x, int y, const SDL_FRect* src_rect, SDL_FlipMode flip) void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 target_color, const SDL_FRect* src_rect, SDL_FlipMode flip) const { const auto CANVAS = Rendering::activeCanvas(); + const auto CLIP = Rendering::activeClip(); Uint32* dst = CANVAS.pixels; const int DST_W = CANVAS.width; - const int DST_H = CANVAS.height; + const int CLIP_X0 = CLIP.x; + const int CLIP_Y0 = CLIP.y; + const int CLIP_X1 = CLIP.x + CLIP.w; + const int CLIP_Y1 = CLIP.y + CLIP.h; const int SX = (src_rect != nullptr) ? static_cast(src_rect->x) : 0; const int SY = (src_rect != nullptr) ? static_cast(src_rect->y) : 0; @@ -109,7 +113,7 @@ void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 tar const int SRC_Y = (flip == SDL_FLIP_VERTICAL) ? (SY + sh - 1 - iy) : (SY + iy); const int DST_X = x + ix; const int DST_Y = y + iy; - if (DST_X < 0 || DST_X >= DST_W || DST_Y < 0 || DST_Y >= DST_H) { continue; } + if (DST_X < CLIP_X0 || DST_X >= CLIP_X1 || DST_Y < CLIP_Y0 || DST_Y >= CLIP_Y1) { continue; } const Uint8 COLOR = src[SRC_X + (SRC_Y * surface_data_->width)]; if (COLOR == transparent_color_) { continue; } diff --git a/source/core/rendering/text.hpp b/source/core/rendering/text.hpp index 6dac085..ff66ef2 100644 --- a/source/core/rendering/text.hpp +++ b/source/core/rendering/text.hpp @@ -47,6 +47,14 @@ class Text { Text(const std::shared_ptr& surface, const std::shared_ptr& text_file); ~Text() = default; + // Kerning per defecte de la font (separació entre glifs en píxels, sumat + // a l'amplada natural). Els callers poden passar un kerning explícit a + // write/length, o llegir aquest valor i reenviar-lo; així cada font pot + // tenir un kerning pròpi (p. ex. negatiu per a displays amb sidebearings + // amples) sense obligar els callers a saber-ho. + void setDefaultKerning(int kerning) { default_kerning_ = kerning; } + [[nodiscard]] auto getDefaultKerning() const -> int { return default_kerning_; } + Text(const Text&) = delete; Text(Text&&) = delete; auto operator=(const Text&) -> Text& = delete; @@ -64,6 +72,7 @@ class Text { [[nodiscard]] auto length(const std::string& text, int kerning = 1) const -> int; [[nodiscard]] auto lengthMonoDigits(const std::string& text, int digit_cell_w, int kerning = 1) const -> int; [[nodiscard]] auto getCharacterSize() const -> int { return box_width_; } + [[nodiscard]] auto getLineHeight() const -> int { return box_height_; } static auto loadTextFile(const std::string& file_path) -> std::shared_ptr; static auto nextCodepoint(const std::string& s, std::size_t& pos) -> std::uint32_t; @@ -76,5 +85,6 @@ class Text { std::unique_ptr sprite_; int box_width_{0}; int box_height_{0}; + int default_kerning_{1}; std::unordered_map offset_; }; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 0226f82..c6bba10 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -29,6 +29,7 @@ #include "game/scenes/intro_scene.hpp" #include "game/scenes/logo_scene.hpp" #include "game/scenes/title_scene.hpp" +#include "game/ui/service_menu.hpp" #include "game/ui/ui_palette.hpp" #include "utils/defaults.hpp" @@ -106,6 +107,7 @@ Director::Director() { // d'ells hagi de conèixer els noms dels recursos concrets. auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); auto hud_text = std::make_shared(hud_font_surface, Defaults::UI::Font::HUD_FNT); + hud_text->setDefaultKerning(Defaults::UI::Font::HUD_KERNING); const auto& ui_palette = Resource::Cache::get().getPalette("ui"); RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text); @@ -116,6 +118,35 @@ Director::Director() { // finals; mentrestant tenen estètica pròpia. auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY); auto placeholder_text = std::make_shared(placeholder_font_surface, Defaults::UI::Font::PLACEHOLDER_FNT); + placeholder_text->setDefaultKerning(Defaults::UI::Font::PLACEHOLDER_KERNING); + + // Font del menú de servei (F9). El títol va amb la variant `display` + // i els items amb `condensed-display` (més compacte perquè caben més + // files). Els dos noms i kernings viuen a `Defaults::UI::Font`. + auto load_menu_font = [&](const char* name, const char* gif, const char* fnt, int kerning) { + const std::string KEY = std::string(name) + ".gif"; + Resource::Cache::get().loadSurface(KEY, gif); + Resource::Cache::get().getSurface(KEY)->setPalette(Resource::Cache::get().getPalette("ui")); + auto text = std::make_shared(Resource::Cache::get().getSurface(KEY), fnt); + text->setDefaultKerning(kerning); + return text; + }; + auto menu_title_text = load_menu_font( + Defaults::UI::Font::MENU_TITLE_NAME, + Defaults::UI::Font::MENU_TITLE_GIF, + Defaults::UI::Font::MENU_TITLE_FNT, + Defaults::UI::Font::MENU_TITLE_KERNING); + auto menu_items_text = load_menu_font( + Defaults::UI::Font::MENU_ITEMS_NAME, + Defaults::UI::Font::MENU_ITEMS_GIF, + Defaults::UI::Font::MENU_ITEMS_FNT, + Defaults::UI::Font::MENU_ITEMS_KERNING); + ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette); + + // Overlay hook: Screen::present() cridarà aquesta lambda al principi, + // ABANS de pujar els píxels al backend GPU. Així el menú queda per + // damunt de qualsevol escena sense que Screen hagi de conèixer game/ui. + Screen::get().setOverlayRenderer([]() { ServiceMenu::get().render(); }); // Flux d'escenes LOGO → TITLE → INTRO → GAME. Cada placeholder rep una // factory que construeix la següent, construïda bottom-up des de GAME. @@ -204,6 +235,24 @@ void Director::processEvents() { if (QUIT_REQUESTED) { current_scene_.reset(); } } +void Director::processMenuInput() { + // Intercepta input abans que l'escena vegi els events. Si el menú està + // obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8, + // F11, F12) ni el jugador els veuen. F9 obre el menú quan està tancat. + // Erase invalida iteradors, per això actualitzem `it` a cada branca. + for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { + if (ServiceMenu::get().isOpen() && ServiceMenu::get().handleEvent(*it)) { + it = EventBuffer::events.erase(it); + } else if (it->type == SDL_EVENT_KEY_DOWN && + it->key.scancode == KeyConfig::get().scancode("menu_toggle")) { + ServiceMenu::get().toggle(); + it = EventBuffer::events.erase(it); + } else { + ++it; + } + } +} + void Director::switchSceneIfRequested() { if (!current_scene_) { return; } if (auto next = current_scene_->nextScene()) { @@ -214,6 +263,11 @@ void Director::switchSceneIfRequested() { } auto Director::iterate() -> SDL_AppResult { + // Ordre important: primer el menú (pot consumir tecles i INJECTAR un + // SDL_EVENT_QUIT des de l'acció "Eixir del joc"), després processEvents + // perquè vegi el QUIT al mateix frame i tanqui l'escena — altrament el + // QUIT injectat es perdria quan es netegi EventBuffer al final d'iterate. + processMenuInput(); processEvents(); if (!current_scene_) { @@ -227,6 +281,7 @@ auto Director::iterate() -> SDL_AppResult { last_ticks_ms_ = NOW_MS; Audio::update(); + ServiceMenu::get().update(delta_ms); current_scene_->update(delta_ms); current_scene_->render(); diff --git a/source/core/system/director.hpp b/source/core/system/director.hpp index 8eec87d..5414ea0 100644 --- a/source/core/system/director.hpp +++ b/source/core/system/director.hpp @@ -22,5 +22,6 @@ class Director { void createSystemFolder(); void processEvents(); + static void processMenuInput(); void switchSceneIfRequested(); }; diff --git a/source/game/options.cpp b/source/game/options.cpp index 9fb479c..8898061 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -488,6 +488,30 @@ namespace Options { g_video.shader.current_crtpi_preset = value; saveToFile(); } + void setAudioEnabled(bool value) { + g_audio.enabled = value; + saveToFile(); + } + void setAudioVolume(float value) { + g_audio.volume = std::clamp(value, 0.0F, 1.0F); + saveToFile(); + } + void setAudioMusicEnabled(bool value) { + g_audio.music.enabled = value; + saveToFile(); + } + void setAudioMusicVolume(float value) { + g_audio.music.volume = std::clamp(value, 0.0F, 1.0F); + saveToFile(); + } + void setAudioSoundEnabled(bool value) { + g_audio.sound.enabled = value; + saveToFile(); + } + void setAudioSoundVolume(float value) { + g_audio.sound.volume = std::clamp(value, 0.0F, 1.0F); + saveToFile(); + } void setConfigFile(const std::string& path) { g_config_file_path = path; diff --git a/source/game/options.hpp b/source/game/options.hpp index 028e137..cf698b0 100644 --- a/source/game/options.hpp +++ b/source/game/options.hpp @@ -126,6 +126,12 @@ namespace Options { void setActiveShaderName(const std::string& value); // "postfx" | "crtpi" void setPostFXPreset(const std::string& value); void setCrtPiPreset(const std::string& value); + void setAudioEnabled(bool value); + void setAudioVolume(float value); + void setAudioMusicEnabled(bool value); + void setAudioMusicVolume(float value); + void setAudioSoundEnabled(bool value); + void setAudioSoundVolume(float value); // --- API pública: càrrega/desament de fitxers --- void setConfigFile(const std::string& path); diff --git a/source/game/options_apply.cpp b/source/game/options_apply.cpp new file mode 100644 index 0000000..621940a --- /dev/null +++ b/source/game/options_apply.cpp @@ -0,0 +1,21 @@ +#include "game/options_apply.hpp" + +#include "core/audio/audio.hpp" +#include "core/rendering/screen.hpp" +#include "game/options.hpp" + +namespace OptionsApply { + + void reapplyShader() { + Screen::get().setActiveShader(Options::buildActiveShader()); + Screen::get().applyPostFXParams(Options::buildPostFXParams()); + Screen::get().applyCrtPiParams(Options::buildCrtPiParams()); + } + + void reapplyAudio() { + if (Audio* audio = Audio::get()) { + audio->applySettings(Options::buildAudioConfig()); + } + } + +} // namespace OptionsApply diff --git a/source/game/options_apply.hpp b/source/game/options_apply.hpp new file mode 100644 index 0000000..d708a7f --- /dev/null +++ b/source/game/options_apply.hpp @@ -0,0 +1,20 @@ +#pragma once + +// Helpers de reaplicació en calent: muten els subsistemes (Screen / Audio) +// perquè reflecteixin el nou estat d'Options sense tocar la finestra ni el +// dispositiu d'àudio. Compartits entre `core/input/global_inputs.cpp` i el +// menú de servei (`game/ui/service_menu.cpp`), que són els dos llocs on la +// UI pot canviar opcions en calent. +namespace OptionsApply { + + // Aplica shader actiu + uniforms PostFX + uniforms CrtPi al Screen segons + // l'estat actual d'Options. S'invoca després de qualsevol canvi a + // video.shader.* (enable, current_shader, preset). + void reapplyShader(); + + // Reenvia la config actual d'Options al subsistema Audio (reaplica volums + // i enables). S'invoca després de qualsevol canvi a audio.* (enable, + // volume, music.*, sound.*). + void reapplyAudio(); + +} // namespace OptionsApply diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp new file mode 100644 index 0000000..78ef6cd --- /dev/null +++ b/source/game/ui/service_menu.cpp @@ -0,0 +1,861 @@ +#include "game/ui/service_menu.hpp" + +#include +#include +#include +#include +#include + +#include "core/audio/audio.hpp" +#include "core/input/key_config.hpp" +#include "core/locale/locale.hpp" +#include "core/rendering/canvas.hpp" +#include "core/rendering/screen.hpp" +#include "core/rendering/text.hpp" +#include "core/system/event_buffer.hpp" +#include "game/options.hpp" +#include "game/options_apply.hpp" +#include "game/ui/ui_palette.hpp" +#include "utils/defaults.hpp" +#include "utils/defines.hpp" +#include "version.h" + +namespace { + + // Geometria de la caixa (píxels de canvas lògic 320×240). + // + // Dos nivells de distància vertical: + // - `GAP_Y` és la distància estructural: marge superior al títol, + // títol↔separador, separador↔primer item i últim item↔marge inferior. + // - `ITEM_GAP_Y` és la distància entre items consecutius (més tight + // per a que la llista no quede massa airejada). + constexpr int BOX_WIDTH = 260; + constexpr int PAD_X = 10; + constexpr int GAP_Y = 6; + constexpr int ITEM_GAP_Y = 2; + + constexpr int MIN_ZOOM = 1; + constexpr int MAX_ZOOM = 6; + constexpr int VOLUME_STEP_PCT = 5; // Pas d'UI (5 %) per als volums. + + // --- Animació --- + // + // `open_anim_` va de 0 (invisible) a 1 (totalment obert). La velocitat + // dicta quant triga a obrir-se o tancar-se. HEIGHT_RATE és el factor de + // suavitzat exponencial per a l'alçada quan canvia el nombre d'items + // visibles dins d'una mateixa pàgina. + constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125 s + constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1 s — més ràpid que obrir + constexpr float HEIGHT_RATE = 12.0F; // smoothing alçada (~150 ms @ 90 %) + constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms; reservat per al lliscament lateral de push/pop. + constexpr float MS_PER_SECOND = 1000.0F; + constexpr std::uint8_t BG_ALPHA = 220; // Opacitat màxima del fons. + + // Les claus de locale viuen al .yaml, però el `menu_toggle` id el llegim + // ací a cada event per evitar cachejar un scancode que KeyConfig recàrrega. + constexpr const char* KEY_ID_MENU_TOGGLE = "menu_toggle"; + + auto boolLabel(bool value) -> std::string { + return Locale::get().text(value ? "menu.values.on" : "menu.values.off"); + } + + auto scalingLabel(Defaults::ScalingMode mode) -> std::string { + switch (mode) { + case Defaults::ScalingMode::DISABLED: + return Locale::get().text("menu.values.scaling_disabled"); + case Defaults::ScalingMode::STRETCH: + return Locale::get().text("menu.values.scaling_stretch"); + case Defaults::ScalingMode::LETTERBOX: + return Locale::get().text("menu.values.scaling_letterbox"); + case Defaults::ScalingMode::OVERSCAN: + return Locale::get().text("menu.values.scaling_overscan"); + case Defaults::ScalingMode::INTEGER: + return Locale::get().text("menu.values.scaling_integer"); + } + return {}; + } + + auto renderInfoLabel(Defaults::RenderInfoPosition pos) -> std::string { + switch (pos) { + case Defaults::RenderInfoPosition::OFF: + return Locale::get().text("menu.values.render_info_off"); + case Defaults::RenderInfoPosition::TOP: + return Locale::get().text("menu.values.render_info_top"); + case Defaults::RenderInfoPosition::BOTTOM: + return Locale::get().text("menu.values.render_info_bottom"); + } + return {}; + } + + // Troba l'índex del preset actiu dins del vector; si no es troba cau a 0 + // perquè el menú mostri el primer element disponible (evita índexs fora + // de rang que petarien `presets[idx]`). + template + auto indexOfPreset(const std::vector& presets, const std::string& name) -> std::size_t { + const auto IT = std::ranges::find_if(presets, + [&](const Preset& p) { return p.name == name; }); + return (IT == presets.end()) ? 0 : std::distance(presets.begin(), IT); + } + + // Ease-out quadratic: 1 - (1 - t)². Accelera al principi i frena al final. + // S'aplica a open_anim_ perquè el menú aparegui amb sensació de "pes". + auto easeOutQuad(float t) -> float { + t = std::clamp(t, 0.0F, 1.0F); + const float INV = 1.0F - t; + return 1.0F - (INV * INV); + } + +} // namespace + +// --- Factories d'Items --- + +auto ServiceMenu::makeToggle( + std::string label_key, + std::function getter, + std::function setter, + std::function visible) -> Item { + Item item; + item.kind = Item::Kind::TOGGLE; + item.label_key = std::move(label_key); + item.get_bool = std::move(getter); + item.set_bool = std::move(setter); + item.visible = std::move(visible); + return item; +} + +auto ServiceMenu::makeCycle( + std::string label_key, + std::function get_index, + std::function get_count, + std::function set_index, + std::function value_label, + std::function visible) -> Item { + Item item; + item.kind = Item::Kind::CYCLE; + item.label_key = std::move(label_key); + item.get_index = std::move(get_index); + item.get_count = std::move(get_count); + item.set_index = std::move(set_index); + item.value_label = std::move(value_label); + item.visible = std::move(visible); + return item; +} + +auto ServiceMenu::makeIntRange( + std::string label_key, + std::function get_int, + std::function set_int, + int min_value, + int max_value, + int step, + std::function int_display, + std::function visible) -> Item { + Item item; + item.kind = Item::Kind::INT_RANGE; + item.label_key = std::move(label_key); + item.get_int = std::move(get_int); + item.set_int = std::move(set_int); + item.min_value = min_value; + item.max_value = max_value; + item.step = step; + item.int_display = std::move(int_display); + item.visible = std::move(visible); + return item; +} + +auto ServiceMenu::makeSubmenu(std::string label_key, std::function push_sub) -> Item { + Item item; + item.kind = Item::Kind::SUBMENU; + item.label_key = std::move(label_key); + item.push_sub = std::move(push_sub); + return item; +} + +auto ServiceMenu::makeAction(std::string label_key, std::function action) -> Item { + Item item; + item.kind = Item::Kind::ACTION; + item.label_key = std::move(label_key); + item.action = std::move(action); + return item; +} + +auto ServiceMenu::makeLabel(std::string text, std::function visible) -> Item { + Item item; + item.kind = Item::Kind::LABEL; + item.label_key = std::move(text); // Per a LABEL el camp és text literal. + item.visible = std::move(visible); + item.selectable = false; + return item; +} + +// --- Cicle de vida --- + +auto ServiceMenu::get() -> ServiceMenu& { + static ServiceMenu instance_; + return instance_; +} + +void ServiceMenu::init(std::shared_ptr title_font, + std::shared_ptr items_font, + const Palette& ui_palette) { + font_ = std::move(title_font); + items_font_ = std::move(items_font); + ui_palette_ = ui_palette; +} + +void ServiceMenu::toggle() { + if (open_) { + close(); + } else { + stack_.clear(); + stack_.push_back(buildRoot()); + open_ = true; + closing_ = false; + animated_h_ = 0.0F; // Comença des de 0 perquè la caixa crega. + } +} + +void ServiceMenu::close() { + if (!open_) { return; } + open_ = false; + closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura. +} + +// `isVisible` inclou l'animació de tancament: retorna true mentre open_anim_>0 +// encara que open_ ja siga false. Així render() es crida fins que la caixa +// ha acabat d'encongir-se. +auto ServiceMenu::isVisible() const -> bool { + return open_ || closing_ || open_anim_ > 0.0F; +} + +void ServiceMenu::update(int delta_ms) { + const float DT = static_cast(delta_ms) / MS_PER_SECOND; + + if (closing_) { + open_anim_ -= CLOSE_SPEED * DT; + if (open_anim_ <= 0.0F) { + open_anim_ = 0.0F; + closing_ = false; + stack_.clear(); // Allibera les pàgines ara que la caixa ja no es veu. + animated_h_ = 0.0F; + transition_active_ = false; + } + } else if (open_ && open_anim_ < 1.0F) { + open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * DT)); + } + + // Transició push/pop entre pàgines. Mentre està activa, el contingut es + // manté amagat i només es veu la caixa redimensionant-se fins a l'alçada + // de la pàgina nova; quan acaba, reapareix el nou contingut. + if (transition_active_) { + transition_progress_ += TRANSITION_SPEED * DT; + if (transition_progress_ >= 1.0F) { + transition_progress_ = 1.0F; + transition_active_ = false; + } + } + + // Alçada suavitzada: quan un item canvia la seua visibilitat en calent + // (p.ex. shader off oculta shader_type i preset), la caixa reacciona amb + // una animació en comptes de pegar un salt brusc. + if (open_ && !stack_.empty()) { + const auto TARGET = static_cast(targetBoxHeight(topConst())); + if (animated_h_ <= 0.0F) { + animated_h_ = TARGET; + } else { + const float DIFF = TARGET - animated_h_; + if (std::fabs(DIFF) < 0.5F) { + animated_h_ = TARGET; + } else { + const float T = std::min(1.0F, HEIGHT_RATE * DT); + animated_h_ += DIFF * T; + } + } + } +} + +// --- Navegació de pila --- + +void ServiceMenu::pushPage(Page page) { + // Cursor inicial: primer item visible I seleccionable; si la pàgina + // només té LABELs, queda a 0 (cap item rebrà focus i l'usuari veu els + // textos sense més). + page.cursor = 0; + for (std::size_t i = 0; i < page.items.size(); ++i) { + const auto& item = page.items[i]; + if ((!item.visible || item.visible()) && item.selectable) { + page.cursor = i; + break; + } + } + stack_.push_back(std::move(page)); + transition_active_ = true; + transition_progress_ = 0.0F; +} + +void ServiceMenu::popPage() { + if (stack_.size() > 1) { + stack_.pop_back(); + transition_active_ = true; + transition_progress_ = 0.0F; + } else { + close(); + } +} + +auto ServiceMenu::top() -> Page& { + return stack_.back(); +} + +auto ServiceMenu::topConst() const -> const Page& { + return stack_.back(); +} + +auto ServiceMenu::isItemVisible(const Item& item) -> bool { + return !item.visible || item.visible(); +} + +// Busca el pròxim item visible i seleccionable en la direcció indicada +// (+1 avall, -1 amunt), amb wrap-around als extrems. Items no seleccionables +// (p.ex. LABELs amb versió/hash) es salten perquè el cursor no hi cau. +void ServiceMenu::moveCursor(int direction) { + if (stack_.empty()) { return; } + auto& page = top(); + if (page.items.empty()) { return; } + + const auto N = page.items.size(); + std::size_t idx = page.cursor; + for (std::size_t step = 0; step < N; ++step) { + idx = (idx + static_cast(direction + static_cast(N))) % N; + if (isItemVisible(page.items[idx]) && page.items[idx].selectable) { + page.cursor = idx; + return; + } + } +} + +void ServiceMenu::activateCycle(const Item& item, int direction) { + if (!item.get_index || !item.get_count || !item.set_index) { return; } + const auto COUNT = item.get_count(); + if (COUNT == 0) { return; } + const auto CUR = item.get_index(); + // `direction` és +1 / -1. Traduïm sempre a un offset positiu dins del + // rang del cicle per evitar barrejar int i size_t. + const std::size_t OFFSET = (direction > 0) ? 1 : (COUNT - 1); + item.set_index((CUR + OFFSET) % COUNT); +} + +void ServiceMenu::activateIntRange(const Item& item, int direction) { + if (!item.get_int || !item.set_int) { return; } + const int CUR = item.get_int(); + const int NEXT = std::clamp(CUR + (direction * item.step), item.min_value, item.max_value); + if (NEXT != CUR) { item.set_int(NEXT); } +} + +// Aplica l'acció de l'item seleccionat. `direction` val +1 per a +// RIGHT/RETURN i -1 per a LEFT; els toggles usen +1 com a "invert". +void ServiceMenu::activateCurrent(int direction) { + if (stack_.empty()) { return; } + const auto& page = topConst(); + if (page.items.empty()) { return; } + const auto& item = page.items[page.cursor]; + if (!isItemVisible(item) || !item.selectable) { return; } + + switch (item.kind) { + case Item::Kind::LABEL: + break; // No interacciona. + case Item::Kind::TOGGLE: + if (item.get_bool && item.set_bool) { + item.set_bool(!item.get_bool()); + } + break; + case Item::Kind::CYCLE: + activateCycle(item, direction); + break; + case Item::Kind::INT_RANGE: + activateIntRange(item, direction); + break; + case Item::Kind::SUBMENU: + if (direction > 0 && item.push_sub) { item.push_sub(); } + break; + case Item::Kind::ACTION: + if (direction > 0 && item.action) { item.action(); } + break; + } +} + +// --- Input --- + +auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool { + if (!open_) { return false; } + if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } + + const auto SC = ev.key.scancode; + if (SC == KeyConfig::get().scancode(KEY_ID_MENU_TOGGLE)) { + close(); + return true; + } + + switch (SC) { + case SDL_SCANCODE_UP: + moveCursor(-1); + break; + case SDL_SCANCODE_DOWN: + moveCursor(+1); + break; + case SDL_SCANCODE_LEFT: + activateCurrent(-1); + break; + case SDL_SCANCODE_RIGHT: + case SDL_SCANCODE_RETURN: + case SDL_SCANCODE_KP_ENTER: + activateCurrent(+1); + break; + case SDL_SCANCODE_BACKSPACE: + case SDL_SCANCODE_ESCAPE: + popPage(); + break; + default: + break; + } + // Mentre el menú està obert consumim TOTS els KEY_DOWN perquè les + // hotkeys globals (F1–F8, F11, F12) no s'activen per sota. + return true; +} + +// --- Builders de pàgines --- + +auto ServiceMenu::buildRoot() -> Page { + Page page; + page.title_key = "menu.titles.root"; + page.items.push_back(makeSubmenu("menu.items.video_submenu", + []() { ServiceMenu::get().pushPage(buildVideo()); })); + page.items.push_back(makeSubmenu("menu.items.audio_submenu", + []() { ServiceMenu::get().pushPage(buildAudio()); })); + page.items.push_back(makeSubmenu("menu.items.system_submenu", + []() { ServiceMenu::get().pushPage(buildSystem()); })); + return page; +} + +auto ServiceMenu::buildVideo() -> Page { + using Mode = Defaults::ScalingMode; + using Pos = Defaults::RenderInfoPosition; + + Page page; + page.title_key = "menu.titles.video"; + + // Zoom: IntRange [1..6] amb display "x{N}". Amagat si fullscreen (com + // a les hotkeys: no té efecte en pantalla completa). + page.items.push_back(makeIntRange( + "menu.items.zoom", + []() { return Options::window().zoom; }, + [](int v) { + Options::setWindowZoom(v); + Screen::get().setWindowZoom(v); + }, + MIN_ZOOM, + MAX_ZOOM, + 1, + [](int v) { return "x" + std::to_string(v); }, + []() { return !Options::window().fullscreen; })); + + page.items.push_back(makeToggle( + "menu.items.screen", + []() { return Options::window().fullscreen; }, + [](bool v) { + Options::setWindowFullscreen(v); + Screen::get().setFullscreen(v); + })); + + page.items.push_back(makeToggle( + "menu.items.vsync", + []() { return Options::video().vsync; }, + [](bool v) { + Options::setVideoVSync(v); + Screen::get().setVSync(v); + })); + + // Cicle dels 5 modes d'escalat. + page.items.push_back(makeCycle( + "menu.items.scaling_mode", + []() { return static_cast(Options::video().scaling_mode); }, + []() { return std::size_t{5}; }, + [](std::size_t idx) { + const auto NEW_MODE = static_cast(idx); + Options::setVideoScalingMode(NEW_MODE); + Screen::get().setScalingMode(NEW_MODE); + }, + [](std::size_t idx) { return scalingLabel(static_cast(idx)); })); + + page.items.push_back(makeToggle( + "menu.items.shader", + []() { return Options::video().shader.enabled; }, + [](bool v) { + Options::setShaderEnabled(v); + OptionsApply::reapplyShader(); + })); + + // Tipus de shader i preset: només visibles si el shader està actiu + // (altrament no tenen efecte observable). Extrets a helpers per mantenir + // la complexitat cognitiva de `buildVideo` sota el llindar de clang-tidy. + page.items.push_back(makeShaderTypeItem()); + page.items.push_back(makeShaderPresetItem()); + + page.items.push_back(makeCycle( + "menu.items.render_info", + []() { return static_cast(Options::video().render_info_position); }, + []() { return static_cast(Defaults::RENDER_INFO_POSITION_COUNT); }, + [](std::size_t idx) { Options::setVideoRenderInfoPosition(static_cast(idx)); }, + [](std::size_t idx) { return renderInfoLabel(static_cast(idx)); })); + + page.items.push_back(makeToggle( + "menu.items.uptime", + []() { return Options::video().render_info_show_time; }, + [](bool v) { Options::setVideoRenderInfoShowTime(v); }, + []() { return Options::video().render_info_position != Pos::OFF; })); + + return page; +} + +auto ServiceMenu::makeShaderTypeItem() -> Item { + return makeCycle( + "menu.items.shader_type", + []() { return Options::video().shader.current_shader == "crtpi" ? std::size_t{1} : std::size_t{0}; }, + []() { return std::size_t{2}; }, + [](std::size_t idx) { + Options::setActiveShaderName(idx == 1 ? "crtpi" : "postfx"); + OptionsApply::reapplyShader(); + }, + [](std::size_t idx) { return idx == 1 ? std::string{"CRT-Pi"} : std::string{"PostFX"}; }, + []() { return Options::video().shader.enabled; }); +} + +auto ServiceMenu::makeShaderPresetItem() -> Item { + // Els getters i setters es ramifiquen per `current_shader`: el selector + // actiu viu a `postfxPresets()` o `crtpiPresets()` segons el tipus triat. + auto is_crtpi = []() { return Options::video().shader.current_shader == "crtpi"; }; + return makeCycle( + "menu.items.preset", + [is_crtpi]() { + return is_crtpi() + ? indexOfPreset(Options::crtpiPresets(), Options::video().shader.current_crtpi_preset) + : indexOfPreset(Options::postfxPresets(), Options::video().shader.current_postfx_preset); + }, + [is_crtpi]() { + return is_crtpi() ? Options::crtpiPresets().size() : Options::postfxPresets().size(); + }, + [is_crtpi](std::size_t idx) { + if (is_crtpi()) { + const auto& presets = Options::crtpiPresets(); + if (idx < presets.size()) { Options::setCrtPiPreset(presets[idx].name); } + } else { + const auto& presets = Options::postfxPresets(); + if (idx < presets.size()) { Options::setPostFXPreset(presets[idx].name); } + } + OptionsApply::reapplyShader(); + }, + [is_crtpi](std::size_t idx) -> std::string { + if (is_crtpi()) { + const auto& presets = Options::crtpiPresets(); + return idx < presets.size() ? presets[idx].name : std::string{}; + } + const auto& presets = Options::postfxPresets(); + return idx < presets.size() ? presets[idx].name : std::string{}; + }, + []() { return Options::video().shader.enabled; }); +} + +auto ServiceMenu::buildAudio() -> Page { + Page page; + page.title_key = "menu.titles.audio"; + + auto volume_display = [](int pct) { return std::to_string(pct); }; + + page.items.push_back(makeToggle( + "menu.items.master_enable", + []() { return Options::audio().enabled; }, + [](bool v) { + Options::setAudioEnabled(v); + OptionsApply::reapplyAudio(); + })); + + page.items.push_back(makeIntRange( + "menu.items.master_volume", + []() { return Audio::toPercent(Options::audio().volume); }, + [](int pct) { + Options::setAudioVolume(Audio::fromPercent(pct)); + OptionsApply::reapplyAudio(); + }, + 0, + 100, + VOLUME_STEP_PCT, + volume_display, + []() { return Options::audio().enabled; })); + + page.items.push_back(makeToggle( + "menu.items.music", + []() { return Options::audio().music.enabled; }, + [](bool v) { + Options::setAudioMusicEnabled(v); + OptionsApply::reapplyAudio(); + }, + []() { return Options::audio().enabled; })); + + page.items.push_back(makeIntRange( + "menu.items.music_volume", + []() { return Audio::toPercent(Options::audio().music.volume); }, + [](int pct) { + Options::setAudioMusicVolume(Audio::fromPercent(pct)); + OptionsApply::reapplyAudio(); + }, + 0, + 100, + VOLUME_STEP_PCT, + volume_display, + []() { return Options::audio().enabled && Options::audio().music.enabled; })); + + page.items.push_back(makeToggle( + "menu.items.sounds", + []() { return Options::audio().sound.enabled; }, + [](bool v) { + Options::setAudioSoundEnabled(v); + OptionsApply::reapplyAudio(); + }, + []() { return Options::audio().enabled; })); + + page.items.push_back(makeIntRange( + "menu.items.sounds_volume", + []() { return Audio::toPercent(Options::audio().sound.volume); }, + [](int pct) { + Options::setAudioSoundVolume(Audio::fromPercent(pct)); + OptionsApply::reapplyAudio(); + }, + 0, + 100, + VOLUME_STEP_PCT, + volume_display, + []() { return Options::audio().enabled && Options::audio().sound.enabled; })); + + return page; +} + +auto ServiceMenu::buildSystem() -> Page { + Page page; + page.title_key = "menu.titles.system"; + // Tots els items són botons/etiquetes; no n'hi ha cap amb columna de + // valor a la dreta, així que els centrem per tenir la jerarquia visual + // pròpia d'un menú d'accions. + page.center_items = true; + + // Versió + hash de commit com a primer item no-seleccionable. Format + // "ver.X.Y.Z (hash)"; el hash el posa CMake a version.h.in. + page.items.push_back(makeLabel( + std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")")); + + // Reiniciar el joc: placeholder sense efecte fins que Director exposi + // un pas de restart en calent (fora d'abast d'aquesta iteració). + page.items.push_back(makeAction( + "menu.items.restart", + []() { + // TODO: connectar amb un hipotètic Director::requestRestart(). + })); + + page.items.push_back(makeAction( + "menu.items.exit_game", + []() { + SDL_Event quit_event{}; + quit_event.type = SDL_EVENT_QUIT; + EventBuffer::events.push_back(quit_event); + })); + + return page; +} + +// --- Render --- + +auto ServiceMenu::formatValue(const Item& item) -> std::string { + switch (item.kind) { + case Item::Kind::LABEL: + return {}; // Es dibuixa només la label (pàgina centrada o no). + case Item::Kind::TOGGLE: + return item.get_bool ? boolLabel(item.get_bool()) : std::string{}; + case Item::Kind::CYCLE: + return (item.value_label && item.get_index) ? item.value_label(item.get_index()) : std::string{}; + case Item::Kind::INT_RANGE: { + if (!item.get_int) { return {}; } + const int V = item.get_int(); + return item.int_display ? item.int_display(V) : std::to_string(V); + } + case Item::Kind::SUBMENU: + return Locale::get().text("menu.values.submenu_arrow"); + case Item::Kind::ACTION: + return {}; + } + return {}; +} + +auto ServiceMenu::targetBoxHeight(const Page& page) const -> int { + if (!font_ || !items_font_) { return 0; } + const int TITLE_H = font_->getLineHeight(); + const int ITEM_H = items_font_->getLineHeight(); + const auto VISIBLE_ITEMS = static_cast(std::ranges::count_if(page.items, + [](const Item& item) { return isItemVisible(item); })); + // Layout: GAP_Y enmig dels blocs estructurals (top, títol↔separador, + // separador↔primer item, últim item↔bottom). Entre items consecutius, + // ITEM_GAP_Y (més petit, perquè els items no queden massa airejats). + int h = GAP_Y; // marge superior + h += TITLE_H + GAP_Y; // títol + gap estructural + if (!page.subtitle.empty()) { + h += TITLE_H + GAP_Y; // subtítol + gap estructural + } + h += 1 + GAP_Y; // separador d'1 px + gap estructural + if (VISIBLE_ITEMS > 0) { + h += VISIBLE_ITEMS * ITEM_H; + h += (VISIBLE_ITEMS - 1) * ITEM_GAP_Y; + h += GAP_Y; // marge inferior + } + return h; +} + +void ServiceMenu::renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const { + const std::uint32_t BG_ARGB = ui_palette_[UiColor::MENU_BG]; + const std::uint32_t BORDER_ARGB = ui_palette_[UiColor::MENU_BORDER]; + const auto ALPHA = static_cast(std::lround(BG_ALPHA * eased)); + + // Fons semi-transparent (el joc queda visible apagat per darrere). + Screen::get().blendRect(box_x, box_y, box_w, box_h, BG_ARGB, ALPHA); + + // Vora opaca per damunt perquè la caixa quedi definida. + Screen::get().fillRect(box_x, box_y, box_w, 1, BORDER_ARGB); + Screen::get().fillRect(box_x, box_y + box_h - 1, box_w, 1, BORDER_ARGB); + Screen::get().fillRect(box_x, box_y, 1, box_h, BORDER_ARGB); + Screen::get().fillRect(box_x + box_w - 1, box_y, 1, box_h, BORDER_ARGB); +} + +void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) const { + const int TITLE_KERNING = font_->getDefaultKerning(); + const int TITLE_H = font_->getLineHeight(); + const int ITEM_KERNING = items_font_->getDefaultKerning(); + const int ITEM_H = items_font_->getLineHeight(); + int cursor_y = box_y + GAP_Y; // marge superior + const int CENTER_X = box_x + (box_w / 2); + + // Amplada reservada per al cursor "> ". La label no canvia de posició + // quan canvia la selecció — el cursor es pinta dins d'aquest carril, + // a l'esquerra, i la label sempre comença al mateix X. + const int CURSOR_WIDTH = items_font_->length("> ", ITEM_KERNING); + const int LABEL_X = box_x + PAD_X + CURSOR_WIDTH; + + font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + Locale::get().text(page.title_key), + TITLE_KERNING, + UiColor::MENU_TITLE); + cursor_y += TITLE_H + GAP_Y; + + if (!page.subtitle.empty()) { + font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + CENTER_X, + cursor_y, + page.subtitle, + TITLE_KERNING, + UiColor::MENU_TITLE); + cursor_y += TITLE_H + GAP_Y; + } + + Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiColor::MENU_BORDER]); + cursor_y += 1 + GAP_Y; + + bool first_item = true; + for (std::size_t i = 0; i < page.items.size(); ++i) { + const auto& item = page.items[i]; + if (!isItemVisible(item)) { continue; } + + if (!first_item) { cursor_y += ITEM_GAP_Y; } + first_item = false; + + const bool SELECTED = item.selectable && (i == page.cursor); + const std::string LABEL_TEXT = (item.kind == Item::Kind::LABEL) + ? item.label_key + : Locale::get().text(item.label_key); + const std::string VALUE = formatValue(item); + + Uint8 label_color = UiColor::MENU_LABEL; + if (item.kind == Item::Kind::LABEL) { + label_color = UiColor::MENU_DISABLED; // to gris per diferenciar-lo dels botons actius + } else if (SELECTED) { + label_color = UiColor::MENU_CURSOR; + } + + int label_x = LABEL_X; + if (page.center_items) { + // En pàgines centrades, la label cau al centre del box i el + // cursor es col·loca immediatament a la seua esquerra. + const int LABEL_W = items_font_->length(LABEL_TEXT, ITEM_KERNING); + label_x = CENTER_X - (LABEL_W / 2); + } + + if (SELECTED) { + items_font_->writeDX(Text::COLOR_FLAG, + label_x - CURSOR_WIDTH, + cursor_y, + "> ", + ITEM_KERNING, + UiColor::MENU_CURSOR); + } + + items_font_->writeDX(Text::COLOR_FLAG, + label_x, + cursor_y, + LABEL_TEXT, + ITEM_KERNING, + label_color); + + // Pàgines centrades no dibuixen columna de valor (s'assumeix que + // només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines + // d'opcions amb layout esquerra-dreta. + if (!page.center_items && !VALUE.empty()) { + const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING); + items_font_->writeDX(Text::COLOR_FLAG, + box_x + box_w - PAD_X - VALUE_W, + cursor_y, + VALUE, + ITEM_KERNING, + SELECTED ? UiColor::MENU_CURSOR : UiColor::MENU_VALUE); + } + + cursor_y += ITEM_H; + } +} + +void ServiceMenu::render() { + if (!isVisible() || stack_.empty() || !font_) { return; } + + const auto& page = topConst(); + const float EASED = easeOutQuad(open_anim_); + + // Alçada animada per suavitzar transicions entre pàgines i canvis de + // visibilitat d'items; l'eased la multiplica per simular el "créixer" + // vertical durant l'obertura. + const int FULL_H = static_cast(animated_h_ > 0.0F ? animated_h_ : targetBoxHeight(page)); + int box_h = std::max(2, static_cast(static_cast(FULL_H) * EASED)); + + const int BOX_X = (GameCanvas::WIDTH - BOX_WIDTH) / 2; + const int BOX_Y = (GameCanvas::HEIGHT - box_h) / 2; + + renderBox(BOX_X, BOX_Y, BOX_WIDTH, box_h, EASED); + + // Clip al rectangle interior de la caixa (dins de la vora, que és d'1 + // px). Així el text que cau fora — durant el creixement inicial, el + // canvi d'alçada en amagar/mostrar items, o el push/pop entre pàgines + // — queda retallat en comptes d'anar a parar sobre l'escena. + Rendering::pushClip({ + .x = BOX_X + 1, + .y = BOX_Y + 1, + .w = BOX_WIDTH - 2, + .h = std::max(0, box_h - 2), + }); + renderPage(page, BOX_X, BOX_Y, BOX_WIDTH); + Rendering::popClip(); +} diff --git a/source/game/ui/service_menu.hpp b/source/game/ui/service_menu.hpp new file mode 100644 index 0000000..47f723e --- /dev/null +++ b/source/game/ui/service_menu.hpp @@ -0,0 +1,216 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "core/rendering/surface.hpp" // Palette + +class Text; + +// Menú de servei (F9). Caixa centrada amb títol i items navegables per +// fletxes + Retorn; Retrocés / Escape tanca la pàgina actual o el menú. +// És accessible només dins de `GameScene` (els placeholders LOGO/TITLE/INTRO +// duren 5 s i no tenen sentit com a entrada al menú; veure plan §11). +// +// La pila de pàgines (`stack_`) permet submenús; la pàgina de dalt és la +// que rep input i es dibuixa. `pushPage` apila, `popPage` treu la de dalt; +// en tornar a quedar amb una sola pàgina, backspace/escape tanca el menú. +class ServiceMenu { + public: + static auto get() -> ServiceMenu&; + + ServiceMenu(const ServiceMenu&) = delete; + ServiceMenu(ServiceMenu&&) = delete; + auto operator=(const ServiceMenu&) -> ServiceMenu& = delete; + auto operator=(ServiceMenu&&) -> ServiceMenu& = delete; + + // Injecta les dues fonts del menú i la paleta UI. Ha de cridar-se una + // sola vegada (des de Director) abans de qualsevol `render()` o + // `toggle()`. Cada font pot portar el seu kerning pre-configurat + // (via `Text::setDefaultKerning`). + void init(std::shared_ptr title_font, + std::shared_ptr items_font, + const Palette& ui_palette); + + // "Obert" = el menú accepta input. Torna `false` durant l'animació de + // tancament (la caixa es veu però ja no respon). + [[nodiscard]] auto isOpen() const -> bool { return open_; } + + void toggle(); + void close(); + + // Avança animacions d'obertura, tancament i alçada. Ha de cridar-se + // cada frame des del Director (no des de cada escena). + void update(int delta_ms); + + void render(); + + // Retorna true si el menú ha consumit l'esdeveniment. Quan el menú està + // obert, consumeix tots els KEY_DOWN perquè les hotkeys globals (F1–F8, + // F11, F12) no es disparin per sota. Quan està tancat sempre retorna + // false; és el caller qui decideix obrir-lo amb la hotkey. + auto handleEvent(const SDL_Event& ev) -> bool; + + private: + // --- Struct d'un item d'una pàgina --- + // + // La unió de totes les "variants" (toggle / cycle / ...) en un sol + // struct amb `std::function` nominalment opcionals simplifica la + // construcció dels items (nomes s'omplen els slots del Kind rellevant) + // a canvi d'una mica d'overhead. Amb una dotzena d'items no és un + // problema de rendiment. + struct Item { + enum class Kind : std::uint8_t { + LABEL, // Text pur, no interacciona; ideal per a versions/notes. + TOGGLE, // ON / OFF amb bool getter/setter + CYCLE, // Índex dins d'una llista; L=prev, R=next (wrap) + INT_RANGE, // L/R descent/ascent per step, clamp a [min,max] + SUBMENU, // RETURN/R: push la pàgina construïda per `pushSub` + ACTION, // RETURN: dispara `action` + }; + Kind kind{Kind::TOGGLE}; + // Per a tots els Kind menys LABEL, `label_key` és una clau de Locale. + // Per a LABEL, és text literal (no passa per Locale) perquè típicament + // porta valors dinàmics (versió, hash de commit, etc.). + std::string label_key; + + std::function get_bool; + std::function set_bool; + + std::function get_index; + std::function get_count; + std::function set_index; + std::function value_label; + + std::function get_int; + std::function set_int; + int min_value{0}; + int max_value{0}; + int step{1}; + std::function int_display; + + std::function push_sub; + std::function action; + + // Si està buit, l'item sempre és visible. Si retorna false, l'item + // es salta tant al render com a la navegació (cursor salta). + std::function visible; + + // Si `false`, el cursor no pot aterrar-hi: es salta a l'hora de + // navegar amb ↑/↓. Default `true` per la gran majoria d'items; les + // etiquetes LABEL el deixen `false` perquè no tenen interacció. + bool selectable{true}; + }; + + struct Page { + std::string title_key; // "menu.titles.video" etc. + std::string subtitle; // Text ja format (buit si no aplica). + std::vector items; + std::size_t cursor{0}; + // Si `true`, els items es dibuixen centrats dins de la caixa i el + // cursor es col·loca immediatament a l'esquerra de l'etiqueta. + // Adequat per a pàgines que només tenen botons d'acció (p.ex. Sistema). + bool center_items{false}; + }; + + ServiceMenu() = default; + ~ServiceMenu() = default; + + // --- Factories d'Items --- + // + // Cada factory omple els slots rellevants del Kind i deixa la resta + // default-construïts. Redueix el soroll al lloc d'ús i, el més important, + // elimina el warning `-Wmissing-designated-field-initializers` que + // apareixeria amb designated-initializers parcials. + [[nodiscard]] static auto makeToggle( + std::string label_key, + std::function getter, + std::function setter, + std::function visible = {}) -> Item; + + [[nodiscard]] static auto makeCycle( + std::string label_key, + std::function get_index, + std::function get_count, + std::function set_index, + std::function value_label, + std::function visible = {}) -> Item; + + [[nodiscard]] static auto makeIntRange( + std::string label_key, + std::function get_int, + std::function set_int, + int min_value, + int max_value, + int step, + std::function int_display = {}, + std::function visible = {}) -> Item; + + [[nodiscard]] static auto makeSubmenu( + std::string label_key, + std::function push_sub) -> Item; + + [[nodiscard]] static auto makeAction( + std::string label_key, + std::function action) -> Item; + + // Etiqueta no seleccionable. `text` és el literal a mostrar (no passa + // per Locale), perquè l'ús principal és mostrar versions o hashos que + // ja es calculen a codi. + [[nodiscard]] static auto makeLabel( + std::string text, + std::function visible = {}) -> Item; + + // --- Navegació i estat --- + void pushPage(Page page); + void popPage(); + [[nodiscard]] auto top() -> Page&; + [[nodiscard]] auto topConst() const -> const Page&; + [[nodiscard]] static auto isItemVisible(const Item& item) -> bool; + void moveCursor(int direction); // +1 / -1, salta items no visibles + void activateCurrent(int direction); // Aplica l'acció segons el Kind + static void activateCycle(const Item& item, int direction); + static void activateIntRange(const Item& item, int direction); + + // --- Constructors de pàgines (viuen a .cpp) --- + // Són static perquè no necessiten estat d'instància: tots els getters + // i setters que capturen dins dels items accedeixen via `Options::` o + // `ServiceMenu::get()` directament. + [[nodiscard]] static auto buildRoot() -> Page; + [[nodiscard]] static auto buildVideo() -> Page; + [[nodiscard]] static auto buildAudio() -> Page; + [[nodiscard]] static auto buildSystem() -> Page; + + // Helpers privats d'items de vídeo: aïllen la lògica de + // `shader_type` i `preset` fora de `buildVideo` perquè no exploti la + // complexitat cognitiva del constructor de pàgines. + [[nodiscard]] static auto makeShaderTypeItem() -> Item; + [[nodiscard]] static auto makeShaderPresetItem() -> Item; + + // --- Render --- + // "Visible" inclou l'animació de tancament: retorna true mentre la caixa + // encara ha de dibuixar-se, encara que open_ siga false. + [[nodiscard]] auto isVisible() const -> bool; + [[nodiscard]] auto targetBoxHeight(const Page& page) const -> int; + + void renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const; + void renderPage(const Page& page, int box_x, int box_y, int box_w) const; + [[nodiscard]] static auto formatValue(const Item& item) -> std::string; + + // --- Estat --- + std::shared_ptr font_; // Títol del menú (`ari-w9500-display`). + std::shared_ptr items_font_; // Items del menú (`ari-w9500-condensed-display`). + Palette ui_palette_{}; + std::vector stack_; + bool open_{false}; + bool closing_{false}; // `true` entre `close()` i fi de l'animació. + float open_anim_{0.0F}; // 0 = invisible, 1 = totalment obert. + float animated_h_{0.0F}; // Alçada suavitzada cap a `targetBoxHeight`. + bool transition_active_{false}; // Push/pop de pàgina en curs. + float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina. +}; diff --git a/source/game/ui/ui_palette.hpp b/source/game/ui/ui_palette.hpp index 4c4a518..0471a6f 100644 --- a/source/game/ui/ui_palette.hpp +++ b/source/game/ui/ui_palette.hpp @@ -11,4 +11,12 @@ namespace UiColor { constexpr auto RENDER_INFO_SHADOW = Defaults::UI::RENDER_INFO_SHADOW; constexpr auto CANVAS_BG = Defaults::UI::CANVAS_BG; + constexpr auto MENU_BG = Defaults::UI::MENU_BG; + constexpr auto MENU_BORDER = Defaults::UI::MENU_BORDER; + constexpr auto MENU_TITLE = Defaults::UI::MENU_TITLE; + constexpr auto MENU_LABEL = Defaults::UI::MENU_LABEL; + constexpr auto MENU_VALUE = Defaults::UI::MENU_VALUE; + constexpr auto MENU_CURSOR = Defaults::UI::MENU_CURSOR; + constexpr auto MENU_DISABLED = Defaults::UI::MENU_DISABLED; + } // namespace UiColor diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 4e8b767..f6fff71 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -97,15 +97,34 @@ namespace Defaults { constexpr const char* DIR = "data/fonts/"; // HUD/Notifier/RenderInfo: font principal de UI del joc. + // El kerning és la separació extra entre glifs (sumat a l'amplada + // natural). 1 és el valor clàssic; valors negatius ajunten glifs + // per a fonts de display amb sidebearings amples. constexpr const char* HUD_NAME = "8bithud"; constexpr const char* HUD_GIF = "data/fonts/8bithud.gif"; constexpr const char* HUD_FNT = "data/fonts/8bithud.fnt"; + constexpr int HUD_KERNING = 1; // Placeholders (LOGO / TITLE / INTRO): font temporal fins que els // placeholders es substitueixin per les seues escenes finals. constexpr const char* PLACEHOLDER_NAME = "ari-w9500"; constexpr const char* PLACEHOLDER_GIF = "data/fonts/ari-w9500.gif"; constexpr const char* PLACEHOLDER_FNT = "data/fonts/ari-w9500.fnt"; + constexpr int PLACEHOLDER_KERNING = 1; + + // Menú de servei (F9): títol en display normal, items en la + // variant condensed-display (més compacta, perquè caben més + // files sense encongir la caixa). Kerning 0 a totes dues perquè + // els glifs de display ja porten sidebearings generosos. + constexpr const char* MENU_TITLE_NAME = "ari-w9500-display"; + constexpr const char* MENU_TITLE_GIF = "data/fonts/ari-w9500-display.gif"; + constexpr const char* MENU_TITLE_FNT = "data/fonts/ari-w9500-display.fnt"; + constexpr int MENU_TITLE_KERNING = 0; + + constexpr const char* MENU_ITEMS_NAME = "ari-w9500-condensed-display"; + constexpr const char* MENU_ITEMS_GIF = "data/fonts/ari-w9500-condensed-display.gif"; + constexpr const char* MENU_ITEMS_FNT = "data/fonts/ari-w9500-condensed-display.fnt"; + constexpr int MENU_ITEMS_KERNING = 0; } // namespace Font // Overlay de RenderInfo (F12). @@ -126,6 +145,15 @@ namespace Defaults { constexpr Uint8 EXIT_TEXT = 88; constexpr Uint8 EXIT_BG = 40; constexpr int EXIT_TIME_MS = 4000; + + // Índexs de paleta per al menú de servei (F9). + constexpr Uint8 MENU_BG = 40; // Fons de la caixa. + constexpr Uint8 MENU_BORDER = 197; // Vora. + constexpr Uint8 MENU_TITLE = 213; // Títol i subtítol. + constexpr Uint8 MENU_LABEL = 47; // Etiquetes dels items. + constexpr Uint8 MENU_VALUE = 197; // Valor actual. + constexpr Uint8 MENU_CURSOR = 88; // Item seleccionat. + constexpr Uint8 MENU_DISABLED = 34; // Label en gris quan no aplica. } // namespace UI // Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no