feat(menu): primera versió del menú de servei
This commit is contained in:
1 parent
e4a8b4cd3e
commit
350625dec0
21 files changed
+1477
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
#include "core/rendering/canvas.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#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<Clip>& {
|
||||
static std::vector<Clip> 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
|
||||
@@ -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
|
||||
@@ -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<std::ptrdiff_t>(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<std::ptrdiff_t>(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<std::uint32_t>(R) << 16) | (static_cast<std::uint32_t>(G) << 8) | static_cast<std::uint32_t>(B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::setOverlayRenderer(std::function<void()> 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());
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<void()> 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<void()> overlay_renderer_;
|
||||
};
|
||||
@@ -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<int>(src_rect->x) : 0;
|
||||
const int SY = (src_rect != nullptr) ? static_cast<int>(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; }
|
||||
|
||||
@@ -47,6 +47,14 @@ class Text {
|
||||
Text(const std::shared_ptr<Surface>& surface, const std::shared_ptr<File>& 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<File>;
|
||||
static auto nextCodepoint(const std::string& s, std::size_t& pos) -> std::uint32_t;
|
||||
@@ -76,5 +85,6 @@ class Text {
|
||||
std::unique_ptr<Sprite> sprite_;
|
||||
int box_width_{0};
|
||||
int box_height_{0};
|
||||
int default_kerning_{1};
|
||||
std::unordered_map<std::uint32_t, Offset> offset_;
|
||||
};
|
||||
@@ -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<Text>(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<Text>(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<Text>(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();
|
||||
|
||||
|
||||
@@ -22,5 +22,6 @@ class Director {
|
||||
|
||||
void createSystemFolder();
|
||||
void processEvents();
|
||||
static void processMenuInput();
|
||||
void switchSceneIfRequested();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,861 @@
|
||||
#include "game/ui/service_menu.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#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 <typename Preset>
|
||||
auto indexOfPreset(const std::vector<Preset>& 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<bool()> getter,
|
||||
std::function<void(bool)> setter,
|
||||
std::function<bool()> 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<std::size_t()> get_index,
|
||||
std::function<std::size_t()> get_count,
|
||||
std::function<void(std::size_t)> set_index,
|
||||
std::function<std::string(std::size_t)> value_label,
|
||||
std::function<bool()> 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<int()> get_int,
|
||||
std::function<void(int)> set_int,
|
||||
int min_value,
|
||||
int max_value,
|
||||
int step,
|
||||
std::function<std::string(int)> int_display,
|
||||
std::function<bool()> 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<void()> 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<void()> 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<bool()> 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<Text> title_font,
|
||||
std::shared_ptr<Text> 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<float>(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<float>(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<std::size_t>(direction + static_cast<int>(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<std::size_t>(Options::video().scaling_mode); },
|
||||
[]() { return std::size_t{5}; },
|
||||
[](std::size_t idx) {
|
||||
const auto NEW_MODE = static_cast<Mode>(idx);
|
||||
Options::setVideoScalingMode(NEW_MODE);
|
||||
Screen::get().setScalingMode(NEW_MODE);
|
||||
},
|
||||
[](std::size_t idx) { return scalingLabel(static_cast<Mode>(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<std::size_t>(Options::video().render_info_position); },
|
||||
[]() { return static_cast<std::size_t>(Defaults::RENDER_INFO_POSITION_COUNT); },
|
||||
[](std::size_t idx) { Options::setVideoRenderInfoPosition(static_cast<Pos>(idx)); },
|
||||
[](std::size_t idx) { return renderInfoLabel(static_cast<Pos>(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<int>(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::uint8_t>(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<int>(animated_h_ > 0.0F ? animated_h_ : targetBoxHeight(page));
|
||||
int box_h = std::max(2, static_cast<int>(static_cast<float>(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();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<Text> title_font,
|
||||
std::shared_ptr<Text> 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<bool()> get_bool;
|
||||
std::function<void(bool)> set_bool;
|
||||
|
||||
std::function<std::size_t()> get_index;
|
||||
std::function<std::size_t()> get_count;
|
||||
std::function<void(std::size_t)> set_index;
|
||||
std::function<std::string(std::size_t)> value_label;
|
||||
|
||||
std::function<int()> get_int;
|
||||
std::function<void(int)> set_int;
|
||||
int min_value{0};
|
||||
int max_value{0};
|
||||
int step{1};
|
||||
std::function<std::string(int)> int_display;
|
||||
|
||||
std::function<void()> push_sub;
|
||||
std::function<void()> 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<bool()> 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<Item> 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<bool()> getter,
|
||||
std::function<void(bool)> setter,
|
||||
std::function<bool()> visible = {}) -> Item;
|
||||
|
||||
[[nodiscard]] static auto makeCycle(
|
||||
std::string label_key,
|
||||
std::function<std::size_t()> get_index,
|
||||
std::function<std::size_t()> get_count,
|
||||
std::function<void(std::size_t)> set_index,
|
||||
std::function<std::string(std::size_t)> value_label,
|
||||
std::function<bool()> visible = {}) -> Item;
|
||||
|
||||
[[nodiscard]] static auto makeIntRange(
|
||||
std::string label_key,
|
||||
std::function<int()> get_int,
|
||||
std::function<void(int)> set_int,
|
||||
int min_value,
|
||||
int max_value,
|
||||
int step,
|
||||
std::function<std::string(int)> int_display = {},
|
||||
std::function<bool()> visible = {}) -> Item;
|
||||
|
||||
[[nodiscard]] static auto makeSubmenu(
|
||||
std::string label_key,
|
||||
std::function<void()> push_sub) -> Item;
|
||||
|
||||
[[nodiscard]] static auto makeAction(
|
||||
std::string label_key,
|
||||
std::function<void()> 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<bool()> 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<Text> font_; // Títol del menú (`ari-w9500-display`).
|
||||
std::shared_ptr<Text> items_font_; // Items del menú (`ari-w9500-condensed-display`).
|
||||
Palette ui_palette_{};
|
||||
std::vector<Page> 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.
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user