Merge branch 'feat/service-menu': menu de servei F12 amb VIDEO/AUDIO/OPCIONS/SISTEMA
This commit is contained in:
@@ -50,6 +50,35 @@ service_menu:
|
||||
title: "MENU DE SERVEI"
|
||||
video: "VIDEO"
|
||||
audio: "AUDIO"
|
||||
options: "OPCIONS"
|
||||
system: "SISTEMA"
|
||||
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"
|
||||
video_resolution: "RESOLUCIO"
|
||||
# Items del submenu OPCIONS
|
||||
options_language: "IDIOMA"
|
||||
options_show_info: "MOSTRAR INFO"
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
@@ -49,6 +49,35 @@ service_menu:
|
||||
title: "SERVICE MENU"
|
||||
video: "VIDEO"
|
||||
audio: "AUDIO"
|
||||
options: "OPTIONS"
|
||||
system: "SYSTEM"
|
||||
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"
|
||||
video_resolution: "RESOLUTION"
|
||||
# Items of OPTIONS submenu
|
||||
options_language: "LANGUAGE"
|
||||
options_show_info: "SHOW INFO"
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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<int>(group));
|
||||
config_.sound_volume = std::clamp(sound_volume, MIN_VOLUME, MAX_VOLUME);
|
||||
engine_->setSoundVolume(effectiveVolume(config_.sound_volume, sound_enabled_), static_cast<int>(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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// service_menu.hpp - Constants del menu de servei (F12)
|
||||
// © 2026 JailDesigner
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
namespace Defaults::ServiceMenu {
|
||||
|
||||
// ---- Mides en coordenades logiques del joc (1280×720) ----
|
||||
// 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 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;
|
||||
|
||||
// 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
|
||||
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 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
|
||||
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 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 ----
|
||||
constexpr const char* SELECT_SOUND = "ui/menu_select.wav";
|
||||
constexpr const char* ACCEPT_SOUND = "ui/menu_accept.wav";
|
||||
|
||||
} // namespace Defaults::ServiceMenu
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3/SDL_gpu.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -390,6 +391,10 @@ namespace Rendering::GPU {
|
||||
color_target.store_op = SDL_GPU_STOREOP_STORE;
|
||||
render_pass_ = SDL_BeginGPURenderPass(cmd_buffer_, &color_target, 1, nullptr);
|
||||
|
||||
// L'scissor és per render pass: en reobrir cal restaurar-lo des del top
|
||||
// de la pila si pushClip/popClip s'han usat mid-frame.
|
||||
applyCurrentScissor();
|
||||
|
||||
SDL_BindGPUGraphicsPipeline(render_pass_, line_pipeline_.get());
|
||||
|
||||
// UBO de líneas usa el tamaño lógico (también del offscreen).
|
||||
@@ -415,6 +420,11 @@ namespace Rendering::GPU {
|
||||
SDL_ReleaseGPUBuffer(dev, vbo);
|
||||
SDL_ReleaseGPUBuffer(dev, ibo);
|
||||
SDL_ReleaseGPUTransferBuffer(dev, tbo);
|
||||
|
||||
// Buidem el batch perquè pushClip/popClip puguin emetre seccions
|
||||
// separades dins el mateix frame sense re-enviar geometria.
|
||||
vertices_.clear();
|
||||
indices_.clear();
|
||||
}
|
||||
|
||||
void GpuFrameRenderer::bloomPass() {
|
||||
@@ -603,6 +613,51 @@ namespace Rendering::GPU {
|
||||
SDL_DrawGPUPrimitives(render_pass_, 3, 1, 0, 0);
|
||||
}
|
||||
|
||||
void GpuFrameRenderer::pushClip(int logical_x, int logical_y, int logical_w, int logical_h) {
|
||||
// Convertim coordenades lògiques (espai del joc, 1280×720) a píxels
|
||||
// físics del offscreen (render_w_ × render_h_). Si l'usuari hi treballa
|
||||
// amb upscale (p.ex. 1920×1080), l'scissor escala proporcionalment.
|
||||
const float SX = render_w_ / logical_w_;
|
||||
const float SY = render_h_ / logical_h_;
|
||||
SDL_Rect rect{
|
||||
.x = static_cast<int>(static_cast<float>(logical_x) * SX),
|
||||
.y = static_cast<int>(static_cast<float>(logical_y) * SY),
|
||||
.w = std::max(0, static_cast<int>(static_cast<float>(logical_w) * SX)),
|
||||
.h = std::max(0, static_cast<int>(static_cast<float>(logical_h) * SY)),
|
||||
};
|
||||
// Emetem tot el batch acumulat *abans* d'activar l'scissor perquè quedi
|
||||
// dibuixat sense retallar.
|
||||
flushBatch();
|
||||
clip_stack_.push_back(rect);
|
||||
applyCurrentScissor();
|
||||
}
|
||||
|
||||
void GpuFrameRenderer::popClip() {
|
||||
// Emetem el batch que s'ha acumulat *dins* del clip actiu.
|
||||
flushBatch();
|
||||
if (!clip_stack_.empty()) {
|
||||
clip_stack_.pop_back();
|
||||
}
|
||||
applyCurrentScissor();
|
||||
}
|
||||
|
||||
void GpuFrameRenderer::applyCurrentScissor() {
|
||||
if (render_pass_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
SDL_Rect rect{};
|
||||
if (clip_stack_.empty()) {
|
||||
// Sense clips: scissor cobreix tot el offscreen.
|
||||
rect.x = 0;
|
||||
rect.y = 0;
|
||||
rect.w = static_cast<int>(render_w_);
|
||||
rect.h = static_cast<int>(render_h_);
|
||||
} else {
|
||||
rect = clip_stack_.back();
|
||||
}
|
||||
SDL_SetGPUScissor(render_pass_, &rect);
|
||||
}
|
||||
|
||||
void GpuFrameRenderer::endFrame() {
|
||||
if (cmd_buffer_ == nullptr) {
|
||||
return;
|
||||
|
||||
@@ -94,6 +94,15 @@ namespace Rendering::GPU {
|
||||
// d'UI (notificacions, panels).
|
||||
void pushRect(float x, float y, float w, float h, float r, float g, float b, float a);
|
||||
|
||||
// Clipping rectangular per a UI (scissor a SDL_GPU). pushClip/popClip
|
||||
// forcen un flush intermedi del batch i activen/restauren l'scissor del
|
||||
// pase actiu. Coordenades en píxels lògics del joc (1280×720); es
|
||||
// converteixen a píxels físics del offscreen automàticament. Stack
|
||||
// d'scissors per a clips niats. Quan la pila queda buida, l'scissor
|
||||
// torna a cobrir el target sencer.
|
||||
void pushClip(int logical_x, int logical_y, int logical_w, int logical_h);
|
||||
void popClip();
|
||||
|
||||
// endFrame: flush del batch de líneas → composite postpro → submit + presenta.
|
||||
void endFrame();
|
||||
|
||||
@@ -168,6 +177,10 @@ namespace Rendering::GPU {
|
||||
std::vector<LineVertex> vertices_;
|
||||
std::vector<uint16_t> indices_;
|
||||
|
||||
// Pila d'scissors actius en píxels físics del offscreen. Buida = sense
|
||||
// clip (full target). Cada push/pop fa un flushBatch i reaplica scissor.
|
||||
std::vector<SDL_Rect> clip_stack_;
|
||||
|
||||
// Estado del frame en curso.
|
||||
SDL_GPUCommandBuffer* cmd_buffer_{nullptr};
|
||||
SDL_GPUTexture* swapchain_texture_{nullptr};
|
||||
@@ -190,6 +203,7 @@ namespace Rendering::GPU {
|
||||
void bloomPass(); // pre-composite: H + V passes sobre les bloom textures
|
||||
void compositePass();
|
||||
void applyFinalViewport();
|
||||
void applyCurrentScissor(); // re-aplica el top de clip_stack_ al render_pass_
|
||||
};
|
||||
|
||||
} // namespace Rendering::GPU
|
||||
|
||||
@@ -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<float>(w), static_cast<float>(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);
|
||||
|
||||
@@ -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).
|
||||
@@ -47,6 +51,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;
|
||||
|
||||
@@ -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(), sdl_.get(), debug_overlay_.get());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// relaunch.cpp - Implementacio del reinici en calent
|
||||
// © 2026 JailDesigner
|
||||
|
||||
#include "core/system/relaunch.hpp"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h> // _execv
|
||||
#else
|
||||
#include <unistd.h> // 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,930 @@
|
||||
// service_menu.cpp - Implementacio del menu de servei
|
||||
// © 2026 JailDesigner
|
||||
|
||||
#include "core/system/service_menu.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <format>
|
||||
#include <utility>
|
||||
|
||||
#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"
|
||||
#include "core/system/debug_overlay.hpp"
|
||||
#include "core/system/relaunch.hpp"
|
||||
#include "core/types.hpp"
|
||||
#include "game/config_yaml.hpp"
|
||||
#include "project.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Easing ease-out quadratic per a l'obertura/tancament. Identic a
|
||||
// aee_arcade service_menu.cpp:114-120.
|
||||
auto easeOutQuad(float t) -> float {
|
||||
t = std::clamp(t, 0.0F, 1.0F);
|
||||
const float INV = 1.0F - t;
|
||||
return 1.0F - (INV * INV);
|
||||
}
|
||||
|
||||
// Canvas logic del joc (constants compartides amb la resta del renderer).
|
||||
constexpr float CANVAS_W = 1280.0F;
|
||||
constexpr float CANVAS_H = 720.0F;
|
||||
|
||||
// Crida pushRect amb un SDL_Color (els components s'escalen a [0..1]).
|
||||
void fillRect(Rendering::Renderer* renderer, float x, float y, float w, float h, SDL_Color color) {
|
||||
renderer->pushRect(x, y, w, h, static_cast<float>(color.r) / 255.0F, static_cast<float>(color.g) / 255.0F, static_cast<float>(color.b) / 255.0F, static_cast<float>(color.a) / 255.0F);
|
||||
}
|
||||
|
||||
void playSelectSound() {
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->playSound(Defaults::ServiceMenu::SELECT_SOUND, Audio::Group::INTERFACE);
|
||||
}
|
||||
}
|
||||
|
||||
void playAcceptSound() {
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->playSound(Defaults::ServiceMenu::ACCEPT_SOUND, Audio::Group::INTERFACE);
|
||||
}
|
||||
}
|
||||
|
||||
// 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<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
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 {
|
||||
|
||||
std::unique_ptr<ServiceMenu> ServiceMenu::instance;
|
||||
|
||||
void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) {
|
||||
instance.reset(new ServiceMenu(renderer, sdl, debug_overlay));
|
||||
}
|
||||
|
||||
void ServiceMenu::destroy() {
|
||||
instance.reset();
|
||||
}
|
||||
|
||||
auto ServiceMenu::get() -> ServiceMenu* {
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
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 {
|
||||
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();
|
||||
// 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;
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
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<void()> on_activate) -> ServiceMenu::Item {
|
||||
return ServiceMenu::Item{
|
||||
.kind = ServiceMenu::Kind::SUBMENU,
|
||||
.label_key = label_key,
|
||||
.label_text = {},
|
||||
.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 = {
|
||||
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] { pushPage(buildSystemPage()); }),
|
||||
};
|
||||
stack_.clear();
|
||||
stack_.push_back(std::move(root));
|
||||
}
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.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); },
|
||||
.on_change = [sdl](int) { sdl->toggleAntialias(); },
|
||||
},
|
||||
// POSTPROCESSAT
|
||||
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()); },
|
||||
.on_change = [sdl](int) { sdl->togglePostFx(); },
|
||||
},
|
||||
};
|
||||
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");
|
||||
};
|
||||
|
||||
// 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<float>(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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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;
|
||||
}
|
||||
|
||||
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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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;
|
||||
}
|
||||
|
||||
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",
|
||||
.label_text = {},
|
||||
.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",
|
||||
.label_text = {},
|
||||
.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<void()> 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",
|
||||
.label_text = {},
|
||||
.selectable = true,
|
||||
.on_activate = [this] { popPage(); },
|
||||
.get_value_text = {},
|
||||
.on_change = {},
|
||||
},
|
||||
Item{
|
||||
.kind = Kind::ACTION,
|
||||
.label_key = "service_menu.confirm_yes",
|
||||
.label_text = {},
|
||||
.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
|
||||
// evitar que vagi lliscant des de la posicio anterior.
|
||||
highlight_snap_ = true;
|
||||
}
|
||||
|
||||
void ServiceMenu::popPage() {
|
||||
if (stack_.size() <= 1) {
|
||||
// Estem a la pagina arrel: BACKSPACE tanca el menu.
|
||||
closing_ = true;
|
||||
playAcceptSound();
|
||||
return;
|
||||
}
|
||||
stack_.pop_back();
|
||||
highlight_snap_ = true;
|
||||
playAcceptSound();
|
||||
}
|
||||
|
||||
void ServiceMenu::moveCursor(int direction) {
|
||||
if (stack_.empty()) {
|
||||
return;
|
||||
}
|
||||
Page& page = stack_.back();
|
||||
const std::size_t N = page.items.size();
|
||||
if (N == 0) {
|
||||
return;
|
||||
}
|
||||
// Cerca el seguent item seleccionable amb wrap-around.
|
||||
std::size_t idx = page.cursor;
|
||||
for (std::size_t step = 0; step < N; ++step) {
|
||||
idx = (idx + static_cast<std::size_t>(direction + static_cast<int>(N))) % N;
|
||||
if (page.items[idx].selectable) {
|
||||
if (idx != page.cursor) {
|
||||
page.cursor = idx;
|
||||
playSelectSound();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServiceMenu::activateCurrent() {
|
||||
// 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;
|
||||
}
|
||||
const Page& page = stack_.back();
|
||||
if (page.cursor >= page.items.size()) {
|
||||
return;
|
||||
}
|
||||
const Item& item = page.items[page.cursor];
|
||||
if (!item.selectable) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
activateCurrent();
|
||||
return true;
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
changeValue(+1);
|
||||
return true;
|
||||
case SDL_SCANCODE_LEFT:
|
||||
changeValue(-1);
|
||||
return true;
|
||||
case SDL_SCANCODE_BACKSPACE:
|
||||
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; // 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<int>(page.items.size());
|
||||
if (N > 0) {
|
||||
h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y;
|
||||
}
|
||||
return static_cast<float>(h);
|
||||
}
|
||||
|
||||
auto ServiceMenu::computeTargetWidth() const -> float {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
if (stack_.empty()) {
|
||||
return static_cast<float>(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 = 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);
|
||||
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<float>(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<float>(HIGHLIGHT_PAD_X)) +
|
||||
(2.0F * static_cast<float>(TEXT_INSET_X));
|
||||
return std::max(static_cast<float>(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<float>(GAP_Y) +
|
||||
static_cast<float>(TITLE_HEIGHT);
|
||||
if (has_subtitle) {
|
||||
items_y0 += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
|
||||
}
|
||||
items_y0 += static_cast<float>(GAP_Y) +
|
||||
static_cast<float>(SEPARATOR_HEIGHT) +
|
||||
static_cast<float>(GAP_Y);
|
||||
return items_y0 + (static_cast<float>(index) * static_cast<float>(ITEM_HEIGHT + ITEM_GAP_Y));
|
||||
}
|
||||
|
||||
void ServiceMenu::update(float delta_time) {
|
||||
if (!open_) {
|
||||
return;
|
||||
}
|
||||
using namespace Defaults::ServiceMenu;
|
||||
|
||||
if (closing_) {
|
||||
open_anim_ -= CLOSE_SPEED * delta_time;
|
||||
if (open_anim_ <= 0.0F) {
|
||||
open_anim_ = 0.0F;
|
||||
animated_h_ = 0.0F;
|
||||
open_ = false;
|
||||
closing_ = false;
|
||||
stack_.clear();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * delta_time));
|
||||
}
|
||||
|
||||
// Smoothing exponencial cap a l'alçada 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
|
||||
// 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 bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
|
||||
const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor, HAS_SUBTITLE);
|
||||
const float TARGET_Y = ITEM_TOP - static_cast<float>(HIGHLIGHT_PAD_Y);
|
||||
const float TARGET_H = static_cast<float>(ITEM_HEIGHT) + (2.0F * static_cast<float>(HIGHLIGHT_PAD_Y));
|
||||
if (highlight_snap_) {
|
||||
highlight_y_ = TARGET_Y;
|
||||
highlight_h_ = TARGET_H;
|
||||
highlight_snap_ = false;
|
||||
} else {
|
||||
const float HL_ALPHA = 1.0F - std::exp(-HIGHLIGHT_RATE * delta_time);
|
||||
highlight_y_ += (TARGET_Y - highlight_y_) * HL_ALPHA;
|
||||
highlight_h_ += (TARGET_H - highlight_h_) * HL_ALPHA;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Dibuixa un rect (BG sombrejat + 4 ticks L als cantons), simulant
|
||||
// un visor sci-fi al voltant de l'item sel·leccionat.
|
||||
void drawHighlightRect(Rendering::Renderer* renderer, float x, float y, float w, float h) {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
if (w <= 0.0F || h <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
// Wash de fons translucid.
|
||||
fillRect(renderer, x, y, w, h, HIGHLIGHT_FILL);
|
||||
|
||||
const auto T = static_cast<float>(HIGHLIGHT_THICKNESS);
|
||||
const auto L = static_cast<float>(HIGHLIGHT_TICK_LEN);
|
||||
|
||||
// Top-left
|
||||
fillRect(renderer, x, y, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x, y, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Top-right
|
||||
fillRect(renderer, x + w - L, y, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x + w - T, y, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Bottom-left
|
||||
fillRect(renderer, x, y + h - T, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x, y + h - L, T, L, HIGHLIGHT_OUTLINE);
|
||||
// Bottom-right
|
||||
fillRect(renderer, x + w - L, y + h - T, L, T, HIGHLIGHT_OUTLINE);
|
||||
fillRect(renderer, x + w - T, y + h - L, T, L, HIGHLIGHT_OUTLINE);
|
||||
}
|
||||
|
||||
// Brackets als 4 cantons de la caixa (sci-fi HUD). Substitueix la vora
|
||||
// completa per un marc obert.
|
||||
void drawCornerBrackets(Rendering::Renderer* renderer, float x, float y, float w, float h) {
|
||||
using namespace Defaults::ServiceMenu;
|
||||
const auto T = static_cast<float>(CORNER_THICKNESS);
|
||||
const auto AH = static_cast<float>(CORNER_ARM_H);
|
||||
const auto AV = static_cast<float>(CORNER_ARM_V);
|
||||
|
||||
// Top-left
|
||||
fillRect(renderer, x, y, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x, y, T, AV, CORNER_COLOR);
|
||||
// Top-right
|
||||
fillRect(renderer, x + w - AH, y, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x + w - T, y, T, AV, CORNER_COLOR);
|
||||
// Bottom-left
|
||||
fillRect(renderer, x, y + h - T, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x, y + h - AV, T, AV, CORNER_COLOR);
|
||||
// Bottom-right
|
||||
fillRect(renderer, x + w - AH, y + h - T, AH, T, CORNER_COLOR);
|
||||
fillRect(renderer, x + w - T, y + h - AV, T, AV, CORNER_COLOR);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ServiceMenu::draw() const {
|
||||
if (!open_ || stack_.empty() || renderer_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
using namespace Defaults::ServiceMenu;
|
||||
|
||||
// Alçada final: smoothing × easing. easeOutQuad afegeix la sensacio
|
||||
// de "snap" al final de l'obertura i l'inici del tancament.
|
||||
const float EASED = easeOutQuad(open_anim_);
|
||||
const float BOX_H = animated_h_ * EASED;
|
||||
if (BOX_H < 1.0F) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float BOX_W = animated_w_;
|
||||
const float BOX_X = (CANVAS_W - BOX_W) * 0.5F;
|
||||
const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F;
|
||||
const float CENTER_X = BOX_X + (BOX_W * 0.5F);
|
||||
|
||||
// Fons semi-transparent.
|
||||
fillRect(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H, BG_COLOR);
|
||||
|
||||
// Brackets als cantons (substitueixen la vora completa).
|
||||
drawCornerBrackets(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H);
|
||||
|
||||
// Clip interior per a tallar text que sortiria del cuadre durant
|
||||
// l'animacio open/close. Marge generos perquè no es mengi els brackets.
|
||||
const int CLIP_X = static_cast<int>(BOX_X + static_cast<float>(CORNER_THICKNESS));
|
||||
const int CLIP_Y = static_cast<int>(BOX_Y + static_cast<float>(CORNER_THICKNESS));
|
||||
const int CLIP_W = static_cast<int>(BOX_W - (2.0F * static_cast<float>(CORNER_THICKNESS)));
|
||||
const int CLIP_H = std::max(0, static_cast<int>(BOX_H - (2.0F * static_cast<float>(CORNER_THICKNESS))));
|
||||
renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H);
|
||||
|
||||
const Page& page = stack_.back();
|
||||
const bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
|
||||
|
||||
// Titol centrat al cim de la caixa.
|
||||
const std::string TITLE = Locale::get().text(page.title_key);
|
||||
const float TITLE_CY = BOX_Y + static_cast<float>(GAP_Y) + (static_cast<float>(TITLE_HEIGHT) * 0.5F);
|
||||
text_.renderCentered(TITLE,
|
||||
Vec2{.x = CENTER_X, .y = TITLE_CY},
|
||||
TITLE_SCALE,
|
||||
TEXT_SPACING,
|
||||
1.0F,
|
||||
TITLE_COLOR);
|
||||
|
||||
// Subtitol opcional: sota el titol, mes petit i apagat.
|
||||
if (HAS_SUBTITLE) {
|
||||
const float SUBTITLE_CY = BOX_Y + static_cast<float>(GAP_Y) +
|
||||
static_cast<float>(TITLE_HEIGHT) +
|
||||
(static_cast<float>(GAP_Y) / 4.0F) +
|
||||
(static_cast<float>(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<float>(GAP_Y) + static_cast<float>(TITLE_HEIGHT);
|
||||
if (HAS_SUBTITLE) {
|
||||
sep_y += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
|
||||
}
|
||||
sep_y += static_cast<float>(GAP_Y) * 0.5F;
|
||||
const float SEP_Y = sep_y;
|
||||
fillRect(renderer_,
|
||||
BOX_X + static_cast<float>(GAP_Y),
|
||||
SEP_Y,
|
||||
BOX_W - (2.0F * static_cast<float>(GAP_Y)),
|
||||
static_cast<float>(SEPARATOR_HEIGHT),
|
||||
SEPARATOR_COLOR);
|
||||
|
||||
// Highlight rect: nomes si la pagina te items i el rect te alçada.
|
||||
if (!page.items.empty() && highlight_h_ > 0.0F) {
|
||||
const float HL_X = BOX_X + static_cast<float>(HIGHLIGHT_PAD_X);
|
||||
const float HL_W = BOX_W - (2.0F * static_cast<float>(HIGHLIGHT_PAD_X));
|
||||
drawHighlightRect(renderer_, HL_X, highlight_y_, HL_W, highlight_h_);
|
||||
}
|
||||
|
||||
// Llista d'items.
|
||||
// - 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<float>(HIGHLIGHT_PAD_X);
|
||||
const float HL_RIGHT = BOX_X + BOX_W - static_cast<float>(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;
|
||||
// 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<float>(ITEM_HEIGHT) * 0.5F);
|
||||
|
||||
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(),
|
||||
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;
|
||||
const float VALUE_W = Graphics::VectorText::getTextWidth(VALUE, ITEM_SCALE, TEXT_SPACING);
|
||||
text_.render(LABEL,
|
||||
Vec2{.x = HL_LEFT + static_cast<float>(TEXT_INSET_X), .y = TEXT_TOP_Y},
|
||||
ITEM_SCALE,
|
||||
TEXT_SPACING,
|
||||
1.0F,
|
||||
COL);
|
||||
text_.render(VALUE,
|
||||
Vec2{.x = HL_RIGHT - static_cast<float>(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();
|
||||
}
|
||||
|
||||
} // namespace System
|
||||
@@ -0,0 +1,145 @@
|
||||
// service_menu.hpp - Menu de servei (singleton)
|
||||
// © 2026 JailDesigner
|
||||
//
|
||||
// Overlay de configuracio global accessible amb F12 des de qualsevol escena
|
||||
// (LOGO, TITLE, GAME). Captura tots els KEY_DOWN excepte F1-F12 i ESC, que
|
||||
// continuen arribant a GlobalEvents. Mentre esta obert, GameScene::update()
|
||||
// fa early return per pausar el joc; LOGO i TITLE continuen renderitzant-se
|
||||
// sota el menu.
|
||||
//
|
||||
// Arquitectura inspirada en aee_arcade service_menu.{hpp,cpp}: pila de
|
||||
// pagines amb cursor, animacio open/close amb easing easeOutQuad i clipping
|
||||
// del contingut mentre la caixa creix/decreix.
|
||||
//
|
||||
// API singleton equivalent a Notifier: init() al startup amb un renderer,
|
||||
// get() retorna el punter, destroy() al teardown.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/graphics/vector_text.hpp"
|
||||
#include "core/rendering/render_context.hpp"
|
||||
|
||||
class SDLManager;
|
||||
|
||||
namespace System {
|
||||
|
||||
class DebugOverlay;
|
||||
|
||||
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 (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<void()> on_activate;
|
||||
// TOGGLE / CYCLE / INT_RANGE: text del valor actual (renderitzat a la dreta).
|
||||
std::function<std::string()> get_value_text;
|
||||
// TOGGLE / CYCLE / INT_RANGE: callback amb +1 (RIGHT/ENTER) o -1 (LEFT).
|
||||
std::function<void(int)> on_change;
|
||||
};
|
||||
|
||||
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<std::string()> subtitle_provider;
|
||||
std::vector<Item> items;
|
||||
std::size_t cursor = 0;
|
||||
};
|
||||
|
||||
// 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*;
|
||||
|
||||
// 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:
|
||||
ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay);
|
||||
|
||||
void buildRootPage();
|
||||
[[nodiscard]] auto buildVideoPage() -> Page;
|
||||
[[nodiscard]] auto buildResolutionPage() 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<void()> on_yes);
|
||||
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;
|
||||
|
||||
// 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_;
|
||||
DebugOverlay* debug_overlay_;
|
||||
Graphics::VectorText text_;
|
||||
|
||||
std::vector<Page> stack_;
|
||||
bool open_ = false;
|
||||
bool closing_ = false;
|
||||
float open_anim_ = 0.0F; // 0..1 raw (sense easing)
|
||||
float animated_h_ = 0.0F; // Alçada animada amb smoothing exponencial
|
||||
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
|
||||
// push/pop de pagina), s'enganxa directament al nou objectiu.
|
||||
float highlight_y_ = 0.0F;
|
||||
float highlight_h_ = 0.0F;
|
||||
bool highlight_snap_ = true;
|
||||
|
||||
static std::unique_ptr<ServiceMenu> instance;
|
||||
};
|
||||
|
||||
} // namespace System
|
||||
@@ -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);
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
#include <memory>
|
||||
|
||||
#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<Director>(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> director(static_cast<Director*>(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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user