diff --git a/docs/service-menu-plan.md b/docs/service-menu-plan.md new file mode 100644 index 0000000..9e8f2f5 --- /dev/null +++ b/docs/service-menu-plan.md @@ -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` + `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 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 ()` — 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(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.