Compare commits

..
11 Commits
32 changed files with 2720 additions and 76 deletions

No files matched your search

+5
View File
@@ -58,6 +58,9 @@ set(APP_SOURCES
source/core/locale/locale.cpp
source/core/input/key_config.cpp
source/core/input/global_inputs.cpp
source/core/input/input_state.cpp
source/core/input/gamepad_manager.cpp
source/core/input/mouse.cpp
source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp
source/core/rendering/notifier.cpp
@@ -78,6 +81,8 @@ set(APP_SOURCES
source/utils/utils.cpp
source/game/debug_config.cpp
source/game/options.cpp
source/game/options_apply.cpp
source/game/ui/service_menu.cpp
source/game/scenes/placeholder_input.cpp
source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp
+3
View File
@@ -29,6 +29,9 @@ keys:
- id: toggle_vsync
code: "F8"
desc: "Activa / desactiva VSync"
- id: menu_toggle
code: "F9"
desc: "Obri / tanca el menú de servei"
- id: toggle_render_info
code: "F12"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
+70 -15
View File
@@ -3,20 +3,75 @@
notification:
test: "notificacio de prova"
gamepad_connected: "connectat"
gamepad_disconnected: "desconnectat"
input:
zoom: "zoom"
fullscreen_on: "pantalla completa"
fullscreen_off: "mode finestra"
shader_on: "shaders activats"
shader_off: "shaders desactivats"
vsync_on: "vsync activat"
vsync_off: "vsync desactivat"
shader: "shader"
preset: "preset"
scaling_disabled: "escalat: sense"
scaling_stretch: "escalat: stretch"
scaling_letterbox: "escalat: letterbox"
scaling_overscan: "escalat: overscan"
scaling_integer: "escalat: enter"
press_again_exit: "prem una altra volta per eixir"
zoom: "Zoom"
fullscreen_on: "Pantalla completa"
fullscreen_off: "Mode finestra"
shader_on: "Shaders activats"
shader_off: "Shaders desactivats"
vsync_on: "VSync activat"
vsync_off: "VSync desactivat"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Escalat sense"
scaling_stretch: "Escalat stretch"
scaling_letterbox: "Escalat letterbox"
scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir"
menu:
titles:
root: "Menú de servei"
video: "Vídeo"
audio: "Àudio"
controls: "Controls"
keyboard: "Teclat"
gamepad: "Comandament"
system: "Sistema"
labels:
player_1: "Jugador 1"
gamepad_unavailable: "No disponible"
items:
video_submenu: "Vídeo"
audio_submenu: "Àudio"
controls_submenu: "Controls"
keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament"
system_submenu: "Sistema"
move_up: "Amunt"
move_down: "Avall"
move_left: "Esquerra"
move_right: "Dreta"
zoom: "Zoom"
screen: "Pantalla completa"
vsync: "VSync"
scaling_mode: "Escalat"
shader: "Shader"
shader_type: "Tipus de shader"
preset: "Preset"
render_info: "Info de render"
uptime: "Mostra temps"
master_enable: "Àudio"
master_volume: "Volum general"
music: "Música"
music_volume: "Volum música"
sounds: "Sons"
sounds_volume: "Volum sons"
restart: "Reiniciar"
exit_game: "Eixir del joc"
values:
on: "Actiu"
off: "Inactiu"
submenu_arrow: ">"
scaling_disabled: "Sense"
scaling_stretch: "Stretch"
scaling_letterbox: "Letterbox"
scaling_overscan: "Overscan"
scaling_integer: "Enter"
render_info_off: "Apagat"
render_info_top: "Dalt"
render_info_bottom: "Baix"
+70 -15
View File
@@ -3,20 +3,75 @@
notification:
test: "test notification"
gamepad_connected: "connected"
gamepad_disconnected: "disconnected"
input:
zoom: "zoom"
fullscreen_on: "fullscreen"
fullscreen_off: "windowed"
shader_on: "shaders on"
shader_off: "shaders off"
vsync_on: "vsync on"
vsync_off: "vsync off"
shader: "shader"
preset: "preset"
scaling_disabled: "scaling: off"
scaling_stretch: "scaling: stretch"
scaling_letterbox: "scaling: letterbox"
scaling_overscan: "scaling: overscan"
scaling_integer: "scaling: integer"
press_again_exit: "press again to exit"
zoom: "Zoom"
fullscreen_on: "Fullscreen"
fullscreen_off: "Windowed"
shader_on: "Shaders on"
shader_off: "Shaders off"
vsync_on: "VSync on"
vsync_off: "VSync off"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Scaling off"
scaling_stretch: "Scaling stretch"
scaling_letterbox: "Scaling letterbox"
scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit"
menu:
titles:
root: "Service menu"
video: "Video"
audio: "Audio"
controls: "Controls"
keyboard: "Keyboard"
gamepad: "Gamepad"
system: "System"
labels:
player_1: "Player 1"
gamepad_unavailable: "Not available"
items:
video_submenu: "Video"
audio_submenu: "Audio"
controls_submenu: "Controls"
keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad"
system_submenu: "System"
move_up: "Up"
move_down: "Down"
move_left: "Left"
move_right: "Right"
zoom: "Zoom"
screen: "Fullscreen"
vsync: "VSync"
scaling_mode: "Scaling"
shader: "Shader"
shader_type: "Shader type"
preset: "Preset"
render_info: "Render info"
uptime: "Show time"
master_enable: "Audio"
master_volume: "Master volume"
music: "Music"
music_volume: "Music volume"
sounds: "Sounds"
sounds_volume: "Sound volume"
restart: "Restart"
exit_game: "Exit game"
values:
on: "On"
off: "Off"
submenu_arrow: ">"
scaling_disabled: "Off"
scaling_stretch: "Stretch"
scaling_letterbox: "Letterbox"
scaling_overscan: "Overscan"
scaling_integer: "Integer"
render_info_off: "Off"
render_info_top: "Top"
render_info_bottom: "Bottom"
+472
View File
@@ -0,0 +1,472 @@
# Pla d'implementació — Menú de servei
Pla per a portar el menú de servei de `../aee` (`source/core/rendering/menu.cpp`)
a `aee_2026`, adaptat a l'arquitectura actual (Scenes + Director instanciat +
`Options` amb setters persistents + `Screen` ARGB + `Text` bitmap). Document de
referència per a implementar-ho en una sessió futura; no s'ha escrit cap codi
encara.
Objectiu visual: caixa centrada amb vora i títol, navegable amb fletxes +
Retorn + Retrocés, oberta i tancada amb F12 (hotkey `menu_toggle`), retolada amb
la font `ari-w9500-display`.
---
## 1. Referència a `../aee`
Tot el que segueix és la base; la còpia mental serveix per a no reinventar res:
- [source/core/rendering/menu.hpp](../../aee/source/core/rendering/menu.hpp) — API
pública (`init/destroy/toggle/close/isOpen/isVisible/render/handleKey`).
- [source/core/rendering/menu.cpp](../../aee/source/core/rendering/menu.cpp) —
implementació completa (676 línies). Conté:
- Struct `Item` i `Page` amb `ItemKind::{Toggle, Cycle, IntRange, Submenu,
KeyBind, Action}`.
- Animació d'obertura/tancament (`OPEN_SPEED`, `CLOSE_SPEED`,
`HEIGHT_RATE`) i transició entre pàgines (`pushPage/popPage`).
- Dibuix manual sobre `Uint32* pixel_data` amb `blendRect/fillRect/drawBorder`.
- `buildRoot/buildVideo/buildAudio/buildControls/buildGame/buildSystem`.
- Claus de `locale/ca.yaml` sota `menu.{titles, items, values}` — es poden
reutilitzar gairebé literalment.
---
## 2. Diferències d'arquitectura rellevants
| Punt | `../aee` | `aee_2026` |
| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| Overlay | `Overlay::render()` dibuixa Menú + Notifier al buffer | No hi ha Overlay. Cada Scene crida `Notifier::render()` i `RenderInfo::render()` |
| Director | Singleton `Director::get()` amb `requestQuit/requestRestart` | Classe instanciada; sortida via `SDL_EVENT_QUIT` a `EventBuffer` |
| `Options` | Membres `inline` mutats directament | Getters const-ref + setters que persisteixen automàticament |
| Setter → subsistema | Dins del mètode (ex. `Screen::toggleVSync`) | El caller crida `Options::setX(v)` **i** `Screen::setX(v)` per separat |
| Remapping tecles | `KeyConfig::scancodePtr(id)` retorna mutable | `KeyConfig` actual no té `scancodePtr` — veure limitació a §5 |
| Render | Pixel-level (blend manual sobre `Uint32*`) | `Screen::fillRect(argb)` + `Text::writeDX`; ARGB cru via `getCanvasArgb()` |
| Font | `8bithud.fnt` via `Text` | `Text` via `Resource::Cache` (paletat sobre `ui`) |
Conseqüències per al disseny:
1. El menú ha d'integrar-se en el pipeline de render de cada Scene, no via
Overlay. Pot ser tan simple com una línia al final de cada `render()`
abans de `Screen::get().present()`, al costat de Notifier/RenderInfo.
2. El menú intercepta input **abans** que `GlobalInputs::update()` processi
les hotkeys (si és obert, només F12 i les fletxes arriben al menú; la
resta d'esdeveniments es consumeixen perquè no activin F1–F11).
3. Els setters de `Options::` ja persisteixen. El menú crida
`Options::setX(v); Screen::setX(v);` com fa `global_inputs.cpp`.
---
## 3. Ubicació i mòduls nous
```
source/game/ui/service_menu.hpp (nou)
source/game/ui/service_menu.cpp (nou)
data/fonts/ari-w9500-display.{fnt,gif} (ja existeixen)
data/locale/ca.yaml (afegir clau arrel `menu:`)
data/locale/en.yaml (afegir clau arrel `menu:`)
data/input/keys.yaml (canviar `toggle_render_info` → `menu_toggle` en F12, o moure F12 a menu i reubicar render_info)
```
Raonament:
- Viu a `game/ui/` perquè el contingut de les pàgines toca `Options::` i
`Screen::` del joc; no és un primitiu de renderització com `Text` o
`Notifier`.
- Exposa l'API com a **singleton** (`ServiceMenu::get()`) per coherència amb
`Notifier` i `RenderInfo`.
- `init()` rep `std::shared_ptr<Text>` + `Palette ui_palette`, igual que
`Notifier::init()`. El Director construeix la font display i la injecta.
Esquelet de l'API:
```cpp
// source/game/ui/service_menu.hpp
class ServiceMenu {
public:
static auto get() -> ServiceMenu&;
void init(std::shared_ptr<Text> font, const Palette& ui_palette);
[[nodiscard]] auto isOpen() const -> bool; // accepta input
[[nodiscard]] auto isVisible() const -> bool; // dibuixa caixa (també tancant-se)
void toggle();
void close();
void update(int delta_ms); // avança animacions
void render(); // dibuixa sobre el canvas de Screen
// Retorna true si el menú ha consumit l'esdeveniment (tecla empassada).
auto handleEvent(const SDL_Event& ev) -> bool;
};
```
---
## 4. Hotkey i integració amb `GlobalInputs`
Decisió: F12 passa a ser `menu_toggle` i el cicle de l'overlay d'info es mou
a una altra tecla (per ex. F10) o es manté a F12 i s'obre el menú amb una
altra (per ex. F9). Opció recomanada:
- `menu_toggle` → **F9** (tecla nova, no conflictiva).
- `toggle_render_info` es queda a F12 (hàbit actual).
Motius: evita trencar la memòria muscular existent i deixa F12 — una tecla
familiar per a debug — lliure per a la seua funció actual.
Afegir a `data/input/keys.yaml`:
```yaml
- id: menu_toggle
code: "F9"
desc: "Obri/tanca el menú de servei"
```
Flux d'input (escena GameScene::update):
```cpp
// Abans de GlobalInputs::update, donar prioritat al menú.
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;
}
}
GlobalInputs::update(delta_ms);
```
- Mentre el menú està obert, les hotkeys (F1–F8, F11, F12) queden bloquejades
perquè els KEY_DOWN els consumeix el menú.
- Escape dins del menú ↔ close (o cancel·lació de captura de tecla, si es
porta el KeyBind — veure §5).
- F9 sempre passa el toggle (encara que el menú estiga obert).
Alternativa més neta a mig termini: un `InputRouter` centralitzat al Director
amb prioritats (Menu → GlobalInputs → Scene). De moment, cadascuna de les
Scenes (`LogoScene`, `TitleScene`, `IntroScene`, `GameScene`) crida el bloc
d'input-del-menú al principi de `update()`, igual que ja fan amb
`GlobalInputs::update`.
---
## 5. Contingut de les pàgines (adaptat a `Options` actuals)
### Root
- Vídeo → submenú
- Àudio → submenú
- Sistema → submenú
_No hi ha Controls ni Game:_ `Options::` actual no exposa remapping de tecles
de joc (les `KeysGame` són constants de `Defaults::`), ni opcions "de joc"
equivalents a `use_new_logo` / `show_preload`. Si en el futur apareixen, es
pot afegir sense reescriure res.
### Vídeo
Items, cadascun mapa el setter+notificador com `global_inputs.cpp`:
| Label | Tipus | Origen | Handler |
| --------------------- | -------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
| `menu.items.zoom` | IntRange | `Options::window().zoom` | `Options::setWindowZoom(v); Screen::get().setWindowZoom(v)` (rang 1–6, clamp) |
| `menu.items.screen` | Toggle | `Options::window().fullscreen` | `Options::setWindowFullscreen(v); Screen::get().setFullscreen(v)` |
| `menu.items.vsync` | Toggle | `Options::video().vsync` | `Options::setVideoVSync(v); Screen::get().setVSync(v)` |
| `menu.items.scaling_mode` | Cycle | `Options::video().scaling_mode` | `Options::setVideoScalingMode(m); Screen::get().setScalingMode(m)` |
| `menu.items.shader` | Toggle | `Options::video().shader.enabled` | `Options::setShaderEnabled(v)` + `reapplyShader()` (copiar helper de `global_inputs.cpp`)|
| `menu.items.shader_type` | Cycle | `Options::video().shader.current_shader` | `Options::setActiveShaderName("postfx"/"crtpi")` + `reapplyShader()` |
| `menu.items.preset` | Cycle | preset actiu segons shader | `Options::setPostFXPreset(s)` o `setCrtPiPreset(s)` + `reapplyShader()` |
| `menu.items.render_info` | Cycle | `Options::video().render_info_position` | `Options::setVideoRenderInfoPosition(p)` |
| `menu.items.uptime` | Toggle | `Options::video().render_info_show_time` | `Options::setVideoRenderInfoShowTime(v)` (sols visible si render_info ≠ OFF) |
Claus `visible` (oculta quan no apliquen):
- `shader_type`, `preset` → només si `shader.enabled == true`.
- `uptime` → només si `render_info_position != OFF`.
### Àudio
| Label | Tipus | Origen | Handler |
| ---------------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------ |
| `menu.items.master_enable` | Toggle | `Options::audio().enabled` | Caldrà afegir `Options::setAudioEnabled(v)` (no hi és avui) + `Audio::applyConfig()` |
| `menu.items.master_volume` | IntRange | `Options::audio().volume` | `Options::setAudioVolume(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.music` | Toggle | `Options::audio().music.enabled` | `Options::setAudioMusicEnabled(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.music_volume` | IntRange | `Options::audio().music.volume` | `Options::setAudioMusicVolume(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.sounds` | Toggle | `Options::audio().sound.enabled` | `Options::setAudioSoundEnabled(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.sounds_volume` | IntRange | `Options::audio().sound.volume` | `Options::setAudioSoundVolume(v)` (afegir) + `Audio::applyConfig()` |
**Dependència prèvia:** `Options::` no té setters d'àudio. Cal afegir-los
seguint el mateix patró que `setVideoVSync` (muten namespace intern +
`saveToFile()`) i exposar `Audio::applyConfig(Audio::Config)` o
`Audio::setVolumes(...)` — depenent del que ja existeixi a `core/audio/`.
Les escales de volum són 0.0–1.0 amb pas 0.05 (5 %), com a `../aee`.
### Sistema
Subtítol: `v<VERSION> (<GIT_HASH>)` — usar el `version.h` generat per CMake
(ja existeix a `source/version.h.in`).
| Label | Tipus | Handler |
| --------------------- | ------ | -------------------------------------------------------------------------------- |
| `menu.items.exit_game`| Action | Injectar un `SDL_EVENT_QUIT` a `EventBuffer::events` perquè `Director::processEvents` el vegi |
_Sense `Restart`_ de moment: Director no té un pas de reinici en calent, i
simular-lo a partir del director actual implica destruir l'escena i
reconstruir el flux — fora d'abast d'aquest menú. Deixar-ho com a TODO si
es necessita.
### Limitació: pàgina Controls diferida
El menú de `../aee` té una pàgina Controls (`buildControls`) que permet
remappar `up/down/left/right` i la tecla de menú. A `aee_2026`:
- Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants
`constexpr`, no mutables.
- `KeyConfig` actual no exposa `scancodePtr(id)`.
Opcions, de menys a més invasiu:
1. **No portar Controls** en aquesta iteració (recomanat per a la primera
versió).
2. Migrar `KeysGame` a `Options::` + afegir `KeyConfig::scancodePtr(id)` per
a hotkeys. Requereix tocar `Player::Controls`, el config.yaml i keys.yaml.
Si s'opta per (2), la pàgina Controls s'afegeix exactament igual que a
`../aee`.
---
## 6. Renderitzat
Dos camins segons nivell d'ambició:
### A. Simple — sòlid opac (MVP)
- Caixa centrada: `Screen::get().fillRect(x, y, w, h, ui_palette_[idx_fons])`.
- Vora: 4 `fillRect` d'1 px amb color `ui_palette_[idx_vora]`.
- Text: `Text::writeDX(CENTER_FLAG | COLOR_FLAG | SHADOW_FLAG, ...)`.
- Cap blend; el joc queda totalment ocult darrere de la caixa (equivalent a
`../aee` però amb `BG_ALPHA = 255`).
Afegir dos índexs nous a `utils/defaults.hpp → UI::`:
```cpp
constexpr Uint8 MENU_BG = /* triar paleta sega-cube */;
constexpr Uint8 MENU_BORDER = /* triar */;
constexpr Uint8 MENU_TITLE = /* blanc */;
constexpr Uint8 MENU_LABEL = /* gris clar */;
constexpr Uint8 MENU_VALUE = /* cian/groc */;
constexpr Uint8 MENU_CURSOR = /* destacat */;
constexpr Uint8 MENU_EMPTY = /* gris */;
```
### B. Semi-transparent (opcional, iteració 2)
Calca el `blendRect` de `../aee`: expandir `Screen::` amb un `blendRect(x,
y, w, h, argb, alpha)` que opere sobre `canvas_.data()`. El canvas és
`std::uint32_t*` format ABGR (equivalent a ARGB little-endian). Amb això, el
fons del menú pot ser `alpha=220` i es veu el joc apagat per darrere.
Mida fixa recomanada per al MVP: `BOX_W = 220`, altura dinàmica (igual que
`../aee`). Canvas lògic d'`aee_2026` és `GameCanvas::WIDTH`/`HEIGHT` —
confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de
hardcoded.
---
## 7. Font `ari-w9500-display`
Ja és al disc i ja té el metadatfnt corresponent
([data/fonts/ari-w9500-display.fnt](../data/fonts/ari-w9500-display.fnt),
11 px d'altura, 12 px de box). És paletable sobre `ui` com les altres
fonts bitmap.
Al `Director::Director()`, afegir després del bloc de fonts HUD/Placeholder:
```cpp
constexpr const char* MENU_FONT_NAME = "ari-w9500-display";
constexpr const char* MENU_FONT_GIF = "data/fonts/ari-w9500-display.gif";
constexpr const char* MENU_FONT_FNT = "data/fonts/ari-w9500-display.fnt";
const std::string MENU_FONT_GIF_KEY = std::string(MENU_FONT_NAME) + ".gif";
Resource::Cache::get().loadSurface(MENU_FONT_GIF_KEY, MENU_FONT_GIF);
Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY)->setPalette(
Resource::Cache::get().getPalette("ui"));
auto menu_font_surface = Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY);
auto menu_text = std::make_shared<Text>(menu_font_surface, MENU_FONT_FNT);
ServiceMenu::get().init(menu_text, ui_palette);
```
Centralitzar les constants a `Defaults::UI::Font::MENU_{NAME,GIF,FNT}` perquè
encaixi amb el memo `project_ui_fonts.md` ("tot a `Defaults::UI::Font`").
---
## 8. Locale
Copiar les claus de `../aee/data/locale/ca.yaml` sota `menu:` (titles, items,
values) a **ca.yaml** i **en.yaml**. El primer commit pot incloure només `ca`;
`en` val la pena fer-lo a la vegada perquè és traducció directa.
Valors a eliminar de la còpia (no apliquen ací):
- `menu.items.{use_new_logo, show_title_credits, show_preload}` (no hi ha
pàgina Game).
- `menu.items.{move_up, move_down, move_left, move_right, menu_key,
restart, aspect_4_3, texture_filter, internal_resolution, supersampling}`
(no portats a l'MVP — veure §5 i les decisions de pàgina Vídeo).
Claus noves que no tenia `../aee` — cap per a l'MVP; si s'afegeixen pàgines
en futures iteracions, traduir a mesura.
---
## 9. Fases d'implementació
Cada fase és un commit. Ordre perquè cada pas compila i s'executa.
### Fase 1 — setters d'àudio a `Options::`
_Preparatori; sense això la pàgina Àudio no pot mutar res._
- Afegir `setAudioEnabled`, `setAudioVolume`, `setAudioMusicEnabled`,
`setAudioMusicVolume`, `setAudioSoundEnabled`, `setAudioSoundVolume` a
`options.hpp/.cpp`. Patró exacte de `setVideoVSync`: muten estat intern +
`saveToFile()`.
- Exposar un mètode de reconfiguració en calent a `core/audio/`
(`Audio::applyConfig` o similar) si no hi és ja.
- Smoke test: `xvfb-run` 5 s, joc arrenca i música sona.
### Fase 2 — hotkey i esquelet `ServiceMenu`
- Afegir `menu_toggle` a `data/input/keys.yaml` amb F9.
- Crear `service_menu.{hpp,cpp}` amb API mínima: `init`, `toggle`, `close`,
`isOpen`, `isVisible`, `update`, `render` (render de caixa buida amb
títol fix "Opcions"), `handleEvent` (només F9, Escape, Backspace).
- Al Director: construir font `ari-w9500-display`, inicialitzar
`ServiceMenu::get()`.
- A `GameScene::update()`: bloc d'input abans de `GlobalInputs::update`
(veure §4). A `GameScene::render()`: `ServiceMenu::get().render()` abans
de `Screen::present()`. **No** replicar encara a Logo/Title/Intro — el
menú només té sentit quan l'usuari juga.
- Smoke test: F9 obri caixa centrada, Escape/F9 la tanca.
### Fase 3 — framework de pàgines
- Portar `Item`, `Page`, `ItemKind`, `stack_`, `pushPage`/`popPage`,
`nextVisibleCursor`, `isVisible(Item)`, `handleKey` de `../aee` (quasi
literalment).
- Portar `renderPageContent` simplificat (sense transicions ni clipping
avançat al principi): render de títol, línia, items amb cursor i valor.
- Afegir les claus `menu.titles.root` i `menu.values.*` a `ca.yaml`/`en.yaml`.
- Implementar `buildRoot()` amb tres entrades Submenu buides (es naveguen
però totes porten a pàgines sense items visibles, text "(Buit)").
- Smoke test: navegació amunt/avall, enter a submenú, backspace torna.
### Fase 4 — Pàgina Vídeo
- Implementar `buildVideo()` amb els items de §5.
- Copiar el helper `reapplyShader()` de `global_inputs.cpp` a un lloc
compartit (p. ex. `game/options_apply.{hpp,cpp}`) perquè el cridin
tant `global_inputs` com el menú, sense duplicar la lògica.
- Afegir claus `menu.items.{zoom,screen,vsync,scaling_mode,shader,shader_type,preset,render_info,uptime}`.
- Smoke test: obrir menú, toggle vsync, canvi d'escala, comprovar que la
notificació NO apareix (és el menú qui fa el canvi, no les hotkeys).
### Fase 5 — Pàgina Àudio
- `buildAudio()` amb els 6 items. Helpers `volPct`/`stepVolume` copiats de
`../aee`.
- Claus `menu.items.{master_enable,master_volume,music,music_volume,sounds,sounds_volume}`.
- Smoke test: baixar volum de música, pujar volum de sons.
### Fase 6 — Pàgina Sistema
- `buildSystem()` amb subtítol de versió i `exit_game` que injecta
`SDL_EVENT_QUIT`.
- Smoke test: obrir sistema, exit_game tanca el joc net.
### Fase 7 (opcional) — polit
- Animació d'obertura/tancament (porta les constants `OPEN_SPEED`,
`CLOSE_SPEED`, `HEIGHT_RATE`, `TRANSITION_SPEED` de `../aee`).
- `blendRect` a `Screen::` per a fons semi-transparent.
- Replicar el bloc d'input del menú a `Title/Intro/Logo` si s'acorda que el
menú hi és accessible també.
- Traduccions EN si no es van fer abans.
---
## 10. Tests
- **Smoke (xvfb-run 5 s)** després de cada fase — memòria `feedback_smoke_test_mandatory`.
- Unitaris amb Catch2 si el framework està muntat (veure `tests/` actual):
- `ServiceMenu::toggle()` idempotent després de `close()`.
- `nextVisibleCursor` salta items ocults.
- `handleEvent` retorna `true` per a KEY_DOWN quan està obert, `false`
quan està tancat.
- Manual:
- Obrir F9 → tancar F9 → obrir de nou (no leak d'estat).
- Entrar a Vídeo → Shader off → verificar que `shader_type` i `preset`
desapareixen (anim o salt).
- Tancar el joc, re-obrir, verificar que `config.yaml` conserva els
canvis (els fa `Options::setX`).
---
## 11. Riscos i punts oberts
- **Input routing als altres Scenes.** Si l'usuari vol obrir el menú
durant el LOGO/TITLE/INTRO, cal replicar el bloc d'input. Decisió: no.
Motiu: són placeholders amb 5 s de durada; obrir un menú allà és
overkill. Anotar-ho a la capçalera de `service_menu.hpp`.
- **`EventBuffer::events.erase(it)`** invalida els iteradors — mà amb el
loop. Patró amb `it = events.erase(it)` ↔ `++it` és segur.
- **Blend rect vs. GPU pipeline.** Si el shader PostFX reprocessa el
canvas, el menú ha d'estar pintat *abans* del shader (i ja ho està,
perquè el canvas ARGB va al shader). Res a fer; tot passa per
`Screen::present()` al final.
- **Captura de tecla (`KeyBind`).** No s'implementa aquesta iteració
(§5). Si es porta, l'event router ha de distingir "menú obert i
capturant" — el menú consumeix **tota** tecla, no només Enter/Backspace/etc.
- **`ari-w9500-display` és paletada.** Ha de compartir la paleta `ui` amb
la resta d'overlays. Verificar que el GIF només té la tinta a l'índex
`Text::INK_COLOR_INDEX = 1`; si no, el font_gen l'ha de regenerar (veure
memòria `reference_font_generator`).
- **F9 lliure?** Abans d'agafar F9 per al `menu_toggle`, confirmar que
cap altra hotkey hi és mapejada en builds futures.
---
## 12. Resum d'arxius a tocar
**Nous:**
- `source/game/ui/service_menu.hpp`
- `source/game/ui/service_menu.cpp`
- (opcional) `source/game/options_apply.{hpp,cpp}` per compartir
`reapplyShader` amb `global_inputs.cpp`.
**Modificats:**
- `source/utils/defaults.hpp` — afegir índexs `UI::MENU_*` + `UI::Font::MENU_*`.
- `source/game/options.hpp/.cpp` — setters d'àudio (fase 1).
- `source/core/system/director.cpp` — carregar font menú + `ServiceMenu::get().init(...)`.
- `source/game/scenes/game_scene.cpp` — bloc d'input del menú a `update`,
render del menú a `render` abans de `present`.
- `data/input/keys.yaml` — afegir `menu_toggle: F9`.
- `data/locale/ca.yaml`, `data/locale/en.yaml` — secció `menu:`.
- `CMakeLists.txt` o `source/CMakeLists.txt` — afegir el nou `service_menu.cpp`.
**Sense tocar per a l'MVP:**
- `source/core/input/key_config.*` (no cal `scancodePtr`).
- `source/core/audio/*` si ja exposa configuració en calent; si no, mínima
adició.
- LogoScene/TitleScene/IntroScene.
+163
View File
@@ -0,0 +1,163 @@
#include "core/input/gamepad_manager.hpp"
#include <algorithm>
#include <iostream>
#include <string>
#include <utility>
#include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp"
namespace {
#ifdef __EMSCRIPTEN__
// A Emscripten, SDL 3.4+ no casa el GUID dels mandos web: Chrome/Android
// no aporten Vendor/Product ID i el parser de SDL extreu dades brutes.
// El W3C Gamepad API, però, garanteix un layout estàndard quan el mapping
// reporta "standard", així que injectem un mapping SDL explícit amb eixe
// layout per al GUID del joystick abans que SDL intenti obrir-lo com a
// gamepad. Fora d'Emscripten és un no-op.
void installWebStandardMapping(SDL_JoystickID jid) {
SDL_GUID guid = SDL_GetJoystickGUIDForID(jid);
char guid_str[33];
SDL_GUIDToString(guid, guid_str, sizeof(guid_str));
const char* name = SDL_GetJoystickNameForID(jid);
if (name == nullptr || *name == '\0') { name = "Standard Gamepad"; }
char mapping[512];
SDL_snprintf(mapping, sizeof(mapping),
"%s,%s,"
"a:b0,b:b1,x:b2,y:b3,"
"leftshoulder:b4,rightshoulder:b5,"
"lefttrigger:b6,righttrigger:b7,"
"back:b8,start:b9,"
"leftstick:b10,rightstick:b11,"
"dpup:b12,dpdown:b13,dpleft:b14,dpright:b15,"
"guide:b16,"
"leftx:a0,lefty:a1,rightx:a2,righty:a3,"
"platform:Emscripten",
guid_str,
name);
SDL_AddGamepadMapping(mapping);
}
#else
void installWebStandardMapping(SDL_JoystickID /*jid*/) {}
#endif
} // namespace
auto GamepadManager::get() -> GamepadManager& {
static GamepadManager instance_;
return instance_;
}
auto GamepadManager::prettyName(const char* raw) -> std::string {
std::string name = (raw != nullptr && *raw != '\0') ? std::string(raw) : std::string("Gamepad");
const auto POS = name.find_first_of("([");
if (POS != std::string::npos) { name.erase(POS); }
while (!name.empty() && name.back() == ' ') { name.pop_back(); }
if (name.empty()) { name = "Gamepad"; }
return name;
}
auto GamepadManager::uniqueName(const std::string& base) const -> std::string {
const bool COLLISION = std::ranges::any_of(
entries_,
[&](const Entry& e) { return e.pretty_name == base; });
if (!COLLISION) { return base; }
// Format " #N" començant a #2 — la primera ocurrència manté el nom net.
for (int i = 2; i < 100; ++i) {
std::string candidate = base + " #" + std::to_string(i);
const bool MATCH = std::ranges::any_of(
entries_,
[&](const Entry& e) { return e.pretty_name == candidate; });
if (!MATCH) { return candidate; }
}
return base; // cas ultra-improbable: 99 mandos idèntics connectats
}
void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
installWebStandardMapping(jid);
if (!SDL_IsGamepad(jid)) { return; }
SDL_Gamepad* pad = SDL_OpenGamepad(jid);
if (pad == nullptr) {
std::cout << "GamepadManager: SDL_OpenGamepad(" << jid
<< ") ha fallat: " << SDL_GetError() << '\n';
return;
}
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
std::cout << "GamepadManager: connectat " << name << '\n';
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name});
if (notify) {
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected"));
}
}
void GamepadManager::removeGamepad(SDL_JoystickID jid) {
auto it = std::ranges::find_if(
entries_,
[jid](const Entry& e) { return e.instance_id == jid; });
if (it == entries_.end()) { return; }
std::string name = std::move(it->pretty_name);
if (it->pad != nullptr) { SDL_CloseGamepad(it->pad); }
std::cout << "GamepadManager: desconnectat " << name << '\n';
entries_.erase(it);
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected"));
}
void GamepadManager::init() {
if (initialized_) { return; }
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: "
<< SDL_GetError() << '\n';
return;
}
// `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i
// continuem amb les definicions internes del sistema.
const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt");
if (ADDED > 0) {
std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n";
}
int count = 0;
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
if (ids != nullptr) {
for (int i = 0; i < count; ++i) {
addGamepad(ids[i], /*notify=*/false);
}
SDL_free(ids);
}
initialized_ = true;
}
void GamepadManager::shutdown() {
for (auto& entry : entries_) {
if (entry.pad != nullptr) { SDL_CloseGamepad(entry.pad); }
}
entries_.clear();
if (initialized_) {
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
initialized_ = false;
}
}
void GamepadManager::handleEvent(const SDL_Event& ev) {
if (ev.type == SDL_EVENT_GAMEPAD_ADDED) {
addGamepad(ev.gdevice.which, /*notify=*/true);
} else if (ev.type == SDL_EVENT_GAMEPAD_REMOVED) {
removeGamepad(ev.gdevice.which);
}
}
auto GamepadManager::getPrimary() const -> SDL_Gamepad* {
return entries_.empty() ? nullptr : entries_.front().pad;
}
auto GamepadManager::getCount() const -> std::size_t {
return entries_.size();
}
auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* {
return idx < entries_.size() ? entries_[idx].pad : nullptr;
}
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <string>
#include <vector>
// Gestor global de gamepads. Descobreix els mandos presents a `init()`
// sense emetre notificacions (el jugador ja els té posats al llançar el
// joc). Durant el runtime, `handleEvent()` processa els events de hot-plug
// i emet notificacions visibles via `Notification::notify`.
//
// El polling real de botons i eixos NO es fa a través d'aquesta classe:
// els consumidors obtenen el `SDL_Gamepad*` primari (o l'enèsim per a
// multi-jugador futur) i el passen a `InputState::getGamepadButton/Axis`,
// que ho filtra pel flag de bloqueig de la UI (mateixa estratègia que
// amb el teclat).
class GamepadManager {
public:
static auto get() -> GamepadManager&;
GamepadManager(const GamepadManager&) = delete;
GamepadManager(GamepadManager&&) = delete;
auto operator=(const GamepadManager&) -> GamepadManager& = delete;
auto operator=(GamepadManager&&) -> GamepadManager& = delete;
// Inicialitza el subsistema SDL de gamepad, intenta carregar un
// `gamecontrollerdb.txt` opcional i obri silenciosament tots els
// mandos ja connectats. Cridat una sola vegada des de Director.
void init();
// Tanca tots els gamepads oberts i treu el subsistema SDL. Cridat
// des de `~Director` abans de `SDL_Quit` per evitar handles orfes.
void shutdown();
// Processa SDL_EVENT_GAMEPAD_ADDED i SDL_EVENT_GAMEPAD_REMOVED
// emesos durant el runtime. Emet notificacions "<nom> connectat" o
// "<nom> desconnectat". El Director decideix quins events li arriben
// via `processGamepadEvents()`.
void handleEvent(const SDL_Event& ev);
// Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap.
// Ús típic: Player 1 i navegació del ServiceMenu.
[[nodiscard]] auto getPrimary() const -> SDL_Gamepad*;
[[nodiscard]] auto getCount() const -> std::size_t;
// Retorna el mand del jugador `idx`-èsim (0-indexed) o `nullptr` si
// encara no hi ha prou mandos connectats. Reservat per a multi-jugador.
[[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*;
private:
struct Entry {
SDL_Gamepad* pad{nullptr};
SDL_JoystickID instance_id{0};
std::string pretty_name;
};
GamepadManager() = default;
~GamepadManager() = default;
// Retalla `raw` al primer '(' o '[' i elimina espais finals per a
// llevar metadata verbosa ("Retroid Controller (vendor: 1001) ...").
// Fallback "Gamepad" si queda buit. No limita l'amplada final — el
// Notifier multilinia ja gestiona noms llargs.
[[nodiscard]] static auto prettyName(const char* raw) -> std::string;
// Si `base` ja existeix al vector, afegeix " #2", " #3", ... fins que
// trobi un nom lliure. Així dos mandos idèntics es distingeixen a les
// notificacions.
[[nodiscard]] auto uniqueName(const std::string& base) const -> std::string;
void addGamepad(SDL_JoystickID jid, bool notify);
void removeGamepad(SDL_JoystickID jid);
std::vector<Entry> entries_;
bool initialized_{false};
};
+6 -12
View File
@@ -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,15 +70,15 @@ 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();
Notification::notify(Locale::get().text("input.shader") + ": " + NEW_NAME);
OptionsApply::reapplyShader();
Notification::notify(Locale::get().text("input.shader") + ' ' + NEW_NAME);
}
// Busca l'entrada actual al vector de presets i passa a la següent
@@ -111,8 +105,8 @@ namespace GlobalInputs {
new_preset = presets[idx].name;
Options::setPostFXPreset(new_preset);
}
reapplyShader();
Notification::notify(Locale::get().text("input.preset") + ": " + new_preset);
OptionsApply::reapplyShader();
Notification::notify(Locale::get().text("input.preset") + ' ' + new_preset);
}
void onNextScalingMode() {
+39
View File
@@ -0,0 +1,39 @@
#include "core/input/input_state.hpp"
#include <array>
namespace InputState {
namespace {
bool blocked = false;
// Buffer retornat quan el bloqueig és actiu. `SDL_GetKeyboardState`
// retorna un pointer d'una mida igual a `SDL_SCANCODE_COUNT`; el
// nostre buffer ha de ser com a mínim igual de gran perquè els
// consumidors puguin indexar-lo amb qualsevol scancode sense
// llegir fora de rang.
auto zeroKeyboard() -> const bool* {
static const std::array<bool, SDL_SCANCODE_COUNT> ZEROS{};
return ZEROS.data();
}
} // namespace
void setBlocked(bool value) { blocked = value; }
auto isBlocked() -> bool { return blocked; }
auto getKeyboardState() -> const bool* {
return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr);
}
auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool {
return (blocked || gp == nullptr) ? false : SDL_GetGamepadButton(gp, b);
}
auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16 {
return (blocked || gp == nullptr) ? static_cast<Sint16>(0) : SDL_GetGamepadAxis(gp, ax);
}
} // namespace InputState
+46
View File
@@ -0,0 +1,46 @@
#pragma once
// Wrapper de l'estat d'input (polling) amb gating de bloqueig.
//
// El service menu (i qualsevol UI modal futura) ha de poder "amagar" l'estat
// d'entrada als consumidors de polling — el jugador, entitats controlades
// per l'usuari, etc. — mentre la UI captura l'atenció, sense interrompre la
// resta del joc. Per això tots els consumidors de polling han de llegir a
// través d'aquest mòdul i no cridar `SDL_GetKeyboardState` directament. Si
// `setBlocked(true)` està actiu, el wrapper retorna un buffer tot-a-zero;
// altrament, la crida és transparent (mateix pointer que SDL).
//
// El menú NO passa per aquí: consumeix events (`SDL_EVENT_KEY_DOWN`) abans
// que l'escena els vegi, així que la seva navegació segueix funcionant per
// molt que el flag estigui posat.
//
// El mateix patró aplica al gamepad: `getGamepadButton`/`getGamepadAxis`
// retornen zero/false quan el bloqueig és actiu. Els mandos concrets els
// obtén el caller via `GamepadManager::get().getPrimary()` o similars.
#include <SDL3/SDL.h>
namespace InputState {
// Activa/desactiva el bloqueig global de polling. El ServiceMenu el
// crida a `toggle()`/`close()`. No es toca des de cap altre lloc.
void setBlocked(bool value);
[[nodiscard]] auto isBlocked() -> bool;
// Equivalent a `SDL_GetKeyboardState(nullptr)`: retorna un buffer de
// `SDL_SCANCODE_COUNT` bools. Si l'input està bloquejat, tots zero —
// el consumidor veu com si cap tecla estigués premuda, sense branques
// explícites. El buffer és estable mentre dura la sessió; no cal
// guardar-lo frame a frame.
[[nodiscard]] auto getKeyboardState() -> const bool*;
// Versió filtrada de `SDL_GetGamepadButton`. Retorna false si el bloqueig
// és actiu o si `gp` és nullptr (cas habitual: no hi ha cap mand).
[[nodiscard]] auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool;
// Versió filtrada de `SDL_GetGamepadAxis`. Retorna 0 si el bloqueig
// és actiu o si `gp` és nullptr.
[[nodiscard]] auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16;
} // namespace InputState
+32
View File
@@ -0,0 +1,32 @@
#include "core/input/mouse.hpp"
namespace Mouse {
namespace {
// Temps (ms) sense moviment de ratolí abans d'amagar el cursor.
constexpr Uint32 HIDE_DELAY_MS = 3000;
Uint32 last_move_time = 0;
bool cursor_visible = true;
} // namespace
void handleEvent(const SDL_Event& ev) {
if (ev.type == SDL_EVENT_MOUSE_MOTION) {
last_move_time = SDL_GetTicks();
if (!cursor_visible) {
SDL_ShowCursor();
cursor_visible = true;
}
}
}
void updateCursorVisibility() {
if (cursor_visible && (SDL_GetTicks() - last_move_time > HIDE_DELAY_MS)) {
SDL_HideCursor();
cursor_visible = false;
}
}
} // namespace Mouse
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <SDL3/SDL.h>
// Auto-ocultació del cursor del ratolí. El joc no usa el ratolí com a
// entrada, així que el cursor del sistema només molesta sobre el canvas.
// El patró és senzill: si l'usuari mou el ratolí, es mostra; si passa
// més de `HIDE_DELAY_MS` sense moviment, s'amaga.
//
// El Director crida `handleEvent()` per cada event i `updateCursorVisibility()`
// una vegada per frame.
namespace Mouse {
void handleEvent(const SDL_Event& ev);
void updateCursorVisibility();
} // namespace Mouse
+43
View File
@@ -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
+24
View File
@@ -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
+76 -14
View File
@@ -1,11 +1,59 @@
#include "core/rendering/notifier.hpp"
#include <algorithm>
#include <numeric>
#include <utility>
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "utils/defines.hpp"
namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
// que no cap passa a la línia següent. Les paraules aïllades més
// llargues que `max_width` es queden a la seva pròpia línia sense
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
// a ~25 caràcters).
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines;
if (text.empty()) {
lines.emplace_back();
return lines;
}
std::string current;
std::size_t i = 0;
while (i < text.size()) {
const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
if (LAST) { break; }
i = SPACE_POS + 1;
}
lines.push_back(current);
return lines;
}
} // namespace
auto Notifier::get() -> Notifier& {
static Notifier instance_;
return instance_;
@@ -34,8 +82,8 @@ void Notifier::update(int delta_ms) {
break;
case Status::VANISHING:
y_ -= SLIDE_SPEED * DT;
if (y_ <= static_cast<float>(-HEIGHT)) {
y_ = static_cast<float>(-HEIGHT - MARGIN_TOP);
if (y_ <= static_cast<float>(-current_box_h_)) {
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::HIDDEN;
}
break;
@@ -45,31 +93,45 @@ void Notifier::update(int delta_ms) {
}
void Notifier::render() {
if (status_ == Status::HIDDEN || text_ == nullptr || current_text_.empty()) { return; }
if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; }
const int TEXT_W = text_->length(current_text_, 1);
const int BOX_W = TEXT_W + (2 * PADDING_H);
const int BOX_X = (GameCanvas::WIDTH - BOX_W) / 2;
const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2;
const int BOX_Y = static_cast<int>(y_);
// Fons sòlid amb la paleta UI que ens ha injectat el caller a init()
// (ARGB directe al canvas, sense tocar cap surface paletada).
Screen::get().fillRect(BOX_X, BOX_Y, BOX_W, HEIGHT, ui_palette_[current_bg_color_]);
Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]);
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
BOX_Y + PADDING_V,
current_text_,
1,
current_text_color_);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP;
int line_y = BOX_Y + PADDING_V;
for (const auto& line : current_lines_) {
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
line_y,
line,
1,
current_text_color_);
line_y += LINE_STEP;
}
}
void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms) {
// Política simple: la nova notificació reemplaça la que hi havia. Per
// gestionar-ne una cua, canviar ací per una FIFO quan calga.
current_text_ = text;
if (text_ == nullptr) { return; }
current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1);
// Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding.
const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); });
current_box_w_ = MAX_LINE_W + (2 * PADDING_H);
const int N = static_cast<int>(current_lines_.size());
current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V);
current_text_color_ = text_color;
current_bg_color_ = bg_color;
remaining_ms_ = time_ms;
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::RISING;
}
+15 -3
View File
@@ -5,9 +5,11 @@
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "core/rendering/hud_constants.hpp"
#include "core/rendering/surface.hpp"
#include "utils/defines.hpp"
class Text;
@@ -19,6 +21,10 @@ class Text;
// API crua: Notifier::get().notify(text, text_color, bg_color, time_ms).
// Per a treballar amb presets pre-definits, vegeu Notification::notify() a
// core/rendering/notification_presets.hpp.
//
// Multilinia: si el text passa de l'amplada màxima (`MAX_TEXT_WIDTH`), es
// parteix per espais i la caixa creix en alçada automàticament. Els textos
// curts (cas habitual) ocupen una sola línia i no canvien d'aspecte.
class Notifier {
public:
static auto get() -> Notifier&;
@@ -39,9 +45,13 @@ class Notifier {
static constexpr int PADDING_H = 6;
static constexpr int PADDING_V = 3;
static constexpr int HEIGHT = Rendering::Hud::FONT_SIZE_PX + (2 * PADDING_V);
static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia.
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas.
static constexpr float SLIDE_SPEED = 120.0F;
// Amplada màxima (sense comptar padding) que pot ocupar el text abans que
// `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del
// canvas perquè la caixa no toqui els laterals.
static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H);
private:
enum class Status : std::uint8_t { HIDDEN,
@@ -55,8 +65,10 @@ class Notifier {
std::shared_ptr<Text> text_;
Palette ui_palette_{};
Status status_{Status::HIDDEN};
float y_{static_cast<float>(-HEIGHT - MARGIN_TOP)};
std::string current_text_;
float y_{0.0F};
std::vector<std::string> current_lines_;
int current_box_w_{0};
int current_box_h_{0};
Uint8 current_text_color_{0};
Uint8 current_bg_color_{0};
int remaining_ms_{0};
+48 -4
View File
@@ -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());
+17
View File
@@ -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_;
};
+6 -2
View File
@@ -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; }
+10
View File
@@ -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_;
};
+97
View File
@@ -12,7 +12,9 @@
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/key_config.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
@@ -29,6 +31,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"
@@ -101,11 +104,17 @@ Director::Director() {
// JA_Music_t que continga, així que no queden streams orfes.
Audio::init(Options::buildAudioConfig());
// Gamepad: descobreix mandos presents silenciosament. A partir d'ara
// els events SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via
// `processGamepadEvents()` i dispararan notificacions.
GamepadManager::get().init();
// Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// 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 +125,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.
@@ -169,6 +207,7 @@ Director::~Director() {
// desfà les llibreries abans que corren els destructors estàtics.
current_scene_.reset();
Resource::Cache::get().clearAudio();
GamepadManager::get().shutdown();
Audio::destroy();
Screen::get().shutdown();
SDL_Quit();
@@ -204,6 +243,50 @@ void Director::processEvents() {
if (QUIT_REQUESTED) { current_scene_.reset(); }
}
void Director::processGamepadEvents() {
// Dues responsabilitats: (1) passar ADDED/REMOVED al GamepadManager
// perquè obri/tanqui mandos i emeti les notificacions hot-plug; (2)
// tractar el botó BACK com a toggle del menú quan està tancat (el
// paral·lel de F9 per al teclat). Quan el menú està obert, el BACK
// passa pel pipeline del `ServiceMenu::handleEvent()` via
// `processMenuInput()` i allà es converteix en `close()`.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
bool consumed = false;
if (it->type == SDL_EVENT_GAMEPAD_ADDED || it->type == SDL_EVENT_GAMEPAD_REMOVED) {
GamepadManager::get().handleEvent(*it);
consumed = true;
} else if (it->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN &&
it->gbutton.button == SDL_GAMEPAD_BUTTON_BACK &&
!ServiceMenu::get().isOpen()) {
ServiceMenu::get().toggle();
consumed = true;
}
if (consumed) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
}
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 +297,18 @@ void Director::switchSceneIfRequested() {
}
auto Director::iterate() -> SDL_AppResult {
// Ocultació automàtica del cursor: observem els events abans que els
// altres steps els consumeixin; Mouse no els modifica, només reacciona
// a SDL_EVENT_MOUSE_MOTION per reaparèixer el cursor.
for (const auto& ev : EventBuffer::events) { Mouse::handleEvent(ev); }
// Ordre important: primer els events de gamepad (hot-plug + BACK que
// obre el menú quan està tancat), després el menú (pot consumir tecles
// i INJECTAR un SDL_EVENT_QUIT des de l'acció "Eixir del joc"), i
// finalment processEvents perquè vegi el QUIT al mateix frame — altrament
// el QUIT injectat es perdria quan es netegi EventBuffer al final.
processGamepadEvents();
processMenuInput();
processEvents();
if (!current_scene_) {
@@ -227,6 +322,8 @@ auto Director::iterate() -> SDL_AppResult {
last_ticks_ms_ = NOW_MS;
Audio::update();
Mouse::updateCursorVisibility();
ServiceMenu::get().update(delta_ms);
current_scene_->update(delta_ms);
current_scene_->render();
+2
View File
@@ -22,5 +22,7 @@ class Director {
void createSystemFolder();
void processEvents();
static void processGamepadEvents();
static void processMenuInput();
void switchSceneIfRequested();
};
+38 -1
View File
@@ -8,6 +8,8 @@
#include <stdexcept>
#include <utility>
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
@@ -19,6 +21,29 @@ namespace {
// Alias local per reduir soroll; la constant viu a Defaults::Player.
constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S;
// Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767).
// Converteix eix analògic en direcció digital; per sota, es considera
// repòs i no contribueix a la velocitat.
constexpr Sint16 STICK_DEADZONE = 12000;
// Acumula la contribució del primer gamepad (cruceta + stick esquerre)
// sobre els acumuladors de velocitat. Extracció d'`update()` per a
// mantenir la complexitat cognitiva sota llindar (threshold 25).
void accumulateGamepadMovement(float& vx, float& vy) {
SDL_Gamepad* pad = GamepadManager::get().getPrimary();
if (pad == nullptr) { return; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; }
if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; }
if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; }
if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; }
}
} // namespace
void Player::loadFrom(const std::string& yaml_path) {
@@ -57,7 +82,11 @@ Player::Player(const std::string& anim_key, float x, float y, const Controls& co
}
void Player::update(float delta_time) {
const bool* keys = SDL_GetKeyboardState(nullptr);
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
// service menu (o qualsevol UI modal) està obert, el wrapper retorna
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
// navega el menú. Polling-blocked; veure `core/input/input_state.hpp`.
const bool* keys = InputState::getKeyboardState();
float vx = 0.0F;
float vy = 0.0F;
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
@@ -65,6 +94,14 @@ void Player::update(float delta_time) {
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
// Cruceta i stick esquerre del primer gamepad (si n'hi ha). També
// respecten el bloqueig d'`InputState`, així que amb el menú obert
// els botons tornen false/0 i no contribueixen a la velocitat.
// Direccions oposades (teclat left + d-pad right) es cancel·len de
// forma natural; premuda simultània en la mateixa direcció no duplica
// la velocitat perquè cada branca només resta/suma una vegada.
accumulateGamepadMovement(vx, vy);
setVelX(vx);
setVelY(vy);
+32 -8
View File
@@ -190,7 +190,7 @@ namespace {
.curvature = 0.0F,
.bleeding = 0.6F,
.flicker = 0.0F},
{.name = "CURVED",
{.name = "Curved",
.vignette = 0.5F,
.scanlines = 0.6F,
.chroma = 0.1F,
@@ -199,7 +199,7 @@ namespace {
.curvature = 0.8F,
.bleeding = 0.0F,
.flicker = 0.0F},
{.name = "SCANLINES",
{.name = "Scanlines",
.vignette = 0.0F,
.scanlines = 0.8F,
.chroma = 0.0F,
@@ -208,7 +208,7 @@ namespace {
.curvature = 0.0F,
.bleeding = 0.0F,
.flicker = 0.0F},
{.name = "SUBTLE",
{.name = "Subtle",
.vignette = 0.3F,
.scanlines = 0.4F,
.chroma = 0.05F,
@@ -217,7 +217,7 @@ namespace {
.curvature = 0.0F,
.bleeding = 0.0F,
.flicker = 0.0F},
{.name = "CRT LIVE",
{.name = "CRT Live",
.vignette = 0.5F,
.scanlines = 0.6F,
.chroma = 0.3F,
@@ -262,7 +262,7 @@ namespace {
// Presets per defecte del shader CRT-Pi, portats de projecte_2026.
auto defaultCrtPiPresets() -> std::vector<Options::CrtPiPreset> {
return {
{.name = "DEFAULT",
{.name = "Default",
.scanline_weight = 6.0F,
.scanline_gap_brightness = 0.12F,
.bloom_factor = 3.5F,
@@ -277,7 +277,7 @@ namespace {
.enable_gamma = true,
.enable_curvature = false,
.enable_sharper = false},
{.name = "CURVED",
{.name = "Curved",
.scanline_weight = 6.0F,
.scanline_gap_brightness = 0.12F,
.bloom_factor = 3.5F,
@@ -292,7 +292,7 @@ namespace {
.enable_gamma = true,
.enable_curvature = true,
.enable_sharper = false},
{.name = "SHARP",
{.name = "Sharp",
.scanline_weight = 6.0F,
.scanline_gap_brightness = 0.12F,
.bloom_factor = 3.5F,
@@ -307,7 +307,7 @@ namespace {
.enable_gamma = true,
.enable_curvature = false,
.enable_sharper = true},
{.name = "MINIMAL",
{.name = "Minimal",
.scanline_weight = 8.0F,
.scanline_gap_brightness = 0.05F,
.bloom_factor = 2.0F,
@@ -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;
+6
View File
@@ -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);
+21
View File
@@ -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
+20
View File
@@ -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
+990
View File
@@ -0,0 +1,990 @@
#include "game/ui/service_menu.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <string>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/input_state.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;
// Les fonts display reserven ~2 px de descendent al fons de la seua
// caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests
// glifs, eixos 2 px queden buits i el gap visual sota el títol sembla
// més gran que el del damunt. Compensem-ho restant-los al càlcul
// d'alçada i al posicionament del separador; així el títol queda
// òpticament centrat entre la vora superior i la línia divisòria.
constexpr int TITLE_DESCENDER_TRIM = 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;
}
auto ServiceMenu::makeKeyBind(std::string label_key,
std::function<SDL_Scancode()> getter,
std::function<void(SDL_Scancode)> setter) -> Item {
Item item;
item.kind = Item::Kind::KEY_BIND;
item.label_key = std::move(label_key);
item.get_scancode = std::move(getter);
item.set_scancode = std::move(setter);
// Si no hi ha setter, l'item queda purament informatiu: no pot capturar
// una tecla nova. Així la primera iteració de la pàgina Controls pot
// mostrar les vinculacions actuals sense tenir encara la infraestructura
// de mutació a `Options::`.
item.selectable = static_cast<bool>(item.set_scancode);
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.
// Bloqueja l'input de polling: els consumidors com `Player` veuran
// l'estat del teclat tot-a-zero mentre el menú sigui obert. Els
// events (KEY_DOWN, etc.) ja els consumeix `processMenuInput`,
// així que la navegació del menú no es veu afectada.
InputState::setBlocked(true);
}
}
void ServiceMenu::close() {
if (!open_) { return; }
open_ = false;
closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura.
// Allibera el polling de seguida — la closing animation és purament
// visual i no ha d'impedir que el jugador es mogui un cop ha tancat.
InputState::setBlocked(false);
}
// `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;
case Item::Kind::KEY_BIND:
// TODO: entrar en mode captura quan hi hagi `set_scancode`.
// De moment no fem res — els items sense setter ja no són
// seleccionables (veure `makeKeyBind`), per tant aquest case
// només es trepitja si algú en fa un amb setter en el futur.
break;
}
}
// --- Input ---
auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
if (!open_) { return false; }
// Botons del gamepad: cruceta per navegar, EAST/NORTH com a OK,
// WEST/SOUTH com a BACK de pàgina, BACK tanca el menú sencer. Quan
// el menú està obert consumim TOTS els BUTTON_DOWN perquè cap botó
// arribi als consumidors de gameplay. Paral·lel al consum total de
// KEY_DOWN més avall.
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
switch (ev.gbutton.button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP:
moveCursor(-1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
moveCursor(+1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
activateCurrent(-1);
break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_NORTH:
activateCurrent(+1);
break;
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_SOUTH:
popPage();
break;
case SDL_GAMEPAD_BUTTON_BACK:
close();
break;
default:
break;
}
return true;
}
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.controls_submenu",
[]() { ServiceMenu::get().pushPage(buildControls()); }));
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::buildControls() -> Page {
Page page;
page.title_key = "menu.titles.controls";
page.items.push_back(makeSubmenu("menu.items.keyboard_submenu",
[]() { ServiceMenu::get().pushPage(buildKeyboardControls()); }));
// Comandament: la pàgina existeix però encara no té infraestructura
// d'integració (no detectem gamepads ni mapegem botons). Entra-hi per
// veure un placeholder fins que la lògica arribi.
page.items.push_back(makeSubmenu("menu.items.gamepad_submenu",
[]() { ServiceMenu::get().pushPage(buildGamepadControls()); }));
return page;
}
auto ServiceMenu::buildKeyboardControls() -> Page {
Page page;
page.title_key = "menu.titles.keyboard";
// Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2,
// només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles
// per sota, sense tocar cap altra cosa.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1")));
// Vinculacions actuals: les llegim directament de `Defaults::KeysGame`.
// Encara no hi ha setter, per tant els items surten com a informatius
// (no seleccionables). La lògica de captura i persistència arribarà en
// una segona iteració amb `Options::keysGameP1()`.
page.items.push_back(makeKeyBind("menu.items.move_up",
[]() { return Defaults::KeysGame::UP; }));
page.items.push_back(makeKeyBind("menu.items.move_down",
[]() { return Defaults::KeysGame::DOWN; }));
page.items.push_back(makeKeyBind("menu.items.move_left",
[]() { return Defaults::KeysGame::LEFT; }));
page.items.push_back(makeKeyBind("menu.items.move_right",
[]() { return Defaults::KeysGame::RIGHT; }));
return page;
}
auto ServiceMenu::buildGamepadControls() -> Page {
Page page;
page.title_key = "menu.titles.gamepad";
page.center_items = true;
// Placeholder: un sol LABEL que indica que la pàgina encara no té
// contingut real. Quan s'afegeixi el suport de gamepad, aquesta
// pàgina es substituirà pel llistat real de botons mapejables.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable")));
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 {};
case Item::Kind::KEY_BIND:
return item.get_scancode ? SDL_GetScancodeName(item.get_scancode()) : std::string{};
}
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 - TITLE_DESCENDER_TRIM; // títol + gap (compensant descendent)
if (!page.subtitle.empty()) {
h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // subtítol idem
}
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 - TITLE_DESCENDER_TRIM;
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 - TITLE_DESCENDER_TRIM;
}
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) {
// Mateix to que el títol de la pàgina: les LABELs actuen com
// a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a
// Sistema) i han de tenir la mateixa presència visual.
label_color = UiColor::MENU_TITLE;
} 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();
}
+235
View File
@@ -0,0 +1,235 @@
#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`
KEY_BIND, // Mostra el nom d'una tecla; RETURN captura-ne de nova.
};
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;
// Per a KEY_BIND: getter que retorna l'scancode actual i setter
// opcional que el muta. Si `set_scancode` és buit, l'item es veu
// però la captura queda deshabilitada (placeholder).
std::function<SDL_Scancode()> get_scancode;
std::function<void(SDL_Scancode)> set_scancode;
// 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;
// Item de vinculació de tecla. Mostra el nom de l'scancode al costat
// dret. Si `setter` és buit, l'item surt com a informatiu (no captura).
// Quan el setter sigui operatiu, RETURN entrarà en mode captura i la
// pròxima tecla que es premi sobreescriurà la vinculació.
[[nodiscard]] static auto makeKeyBind(
std::string label_key,
std::function<SDL_Scancode()> getter,
std::function<void(SDL_Scancode)> setter = {}) -> 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 buildControls() -> Page;
[[nodiscard]] static auto buildKeyboardControls() -> Page;
[[nodiscard]] static auto buildGamepadControls() -> 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.
};
+8
View File
@@ -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
+29 -1
View File
@@ -51,7 +51,7 @@ namespace Defaults {
constexpr bool SHADER_ENABLED = true; // Aplica PostFX; si false, sempre passthrough.
constexpr const char* CURRENT_SHADER = "postfx"; // Shader actiu: "postfx" o "crtpi".
constexpr const char* POSTFX_DEFAULT_PRESET = "CRT"; // Preset inicial PostFX.
constexpr const char* CRTPI_DEFAULT_PRESET = "DEFAULT"; // Preset inicial CrtPi.
constexpr const char* CRTPI_DEFAULT_PRESET = "Default"; // Preset inicial CrtPi.
constexpr const char* POSTFX_FILE = "shaders/postfx.yaml"; // Relatiu a la carpeta de sistema.
constexpr const char* CRTPI_FILE = "shaders/crtpi.yaml"; // Relatiu a la carpeta de sistema.
constexpr RenderInfoPosition RENDER_INFO_POSITION = RenderInfoPosition::OFF; // Overlay de debug amagat per defecte.
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.19.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.20.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {