From a3b03b87be981594a1c9ad3a0e8ef000b20e2a44 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 22 Apr 2026 08:37:59 +0200 Subject: [PATCH] refactor(game): COUP-03 + CONV-02 encapsulate Options state behind setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'estat mutable d'Options (window, video, audio, locale, presets, paths) es mou a un anonymous namespace dins options.cpp. L'API pública del namespace passa a ser: - Getters const-ref: window(), video(), audio(), locale(), configVersion(), postfxPresets(), crtpiPresets(). - Setters específics que muten i persisteixen via saveToFile(): setWindowZoom, setWindowFullscreen, setVideoVSync, setVideoScalingMode, setVideoRenderInfoPosition, setVideoRenderInfoShowTime, setShaderEnabled, setActiveShaderName, setPostFXPreset, setCrtPiPreset. Els setters NO notifiquen els subsistemes: el caller (global_inputs.cpp) crida explícitament Screen::setWindowZoom, reapplyShader, etc. La part "observers" del audit es deixa WONTFIX justificat — notificar des d'Options invertiria la direcció Director→Screen/Audio i Options aprenria del layout dels subsistemes. CONV-02 tanca al pas: la part Options dels "globals sense observers" queda resolta amb la nova encapsulació. Map::* (l'altra meitat de CONV-02) ja s'havia tancat amb PATCH-07. Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run + SDL_VIDEODRIVER=dummy + SIGINT idèntic al baseline de main. clang-tidy i cppcheck nets sobre els fitxers modificats. Refs: docs/audit-game.md (COUP-03, CONV-02, INCONS-01). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/audit-game.md | 18 +- source/core/input/global_inputs.cpp | 103 ++++++------ source/core/system/director.cpp | 2 +- source/game/options.cpp | 248 ++++++++++++++++++---------- source/game/options.hpp | 45 +++-- 5 files changed, 256 insertions(+), 160 deletions(-) diff --git a/docs/audit-game.md b/docs/audit-game.md index 2da7ebc..c19e8aa 100644 --- a/docs/audit-game.md +++ b/docs/audit-game.md @@ -3,7 +3,7 @@ Rama: `audit/game`. Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies). -Estado global: **28 DONE, 2 DONE (parcial), 5 WONTFIX, 4 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED (COUP-02, COUP-03, CONV-02 resta, PATCH-01) van a la futura branca `refactor/options-class`. +Estado global: **28 DONE, 2 DONE (parcial), 5 WONTFIX, 4 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED (COUP-02, COUP-03, CONV-02 resta, PATCH-01) es tanquen a la branca `refactor/options-class` (registre de commits al final del fitxer). Estados posibles: - `PENDING` — identificat, sense tocar @@ -69,14 +69,14 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer |----|--------------|----------|-----------|-------|-------| | COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats | | COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` | -| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DEFERRED | Encapsular tots els globals darrere setters + observers per Screen/Audio/RenderInfo és un refactor ample (equivalent a COUP-01 de l'audit d'àudio, que va anar a una branca pròpia). Es tractarà a `refactor/options-class` perquè toca tots els callers de core/input. Es fa en branca separada | +| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DONE | Estat mutable mogut a anonymous namespace dins `options.cpp`; API pública són getters const-ref (`window()`, `video()`, `audio()`, `locale()`, `postfxPresets()`, `crtpiPresets()`) + setters específics (`setWindowZoom`, `setShaderEnabled`, `setActiveShaderName`, ...) que muten i persisteixen via `saveToFile()`. Els setters NO notifiquen els subsistemes: el caller (`global_inputs.cpp`) crida després `Screen::setWindowZoom`, `reapplyShader()`, etc. La part "observers" del audit es deixa WONTFIX (veure CONV-02): notificar des d'Options invertiria la direcció `Director→Screen/Audio` i Options aprenria del layout dels subsistemes | | COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment | ## 5. Inconsistències internes | ID | Fitxer:línia | Troballa | Severitat | Estat | Notes | |----|--------------|----------|-----------|-------|-------| -| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta | +| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta. NOTA (post `refactor/options-class`): "encapsular" en COUP-03 vol dir ocultar l'estat mutable darrere de l'API del namespace (anonymous namespace + getters/setters), no convertir Options a classe — el patró `namespace per a estat compartit` queda preservat | | INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout | | INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents | | INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter | @@ -97,6 +97,16 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer | ID | Fitxer:línia | Troballa | Severitat | Estat | Notes | |----|--------------|----------|-----------|-------|-------| | CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config | -| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE (parcial) | Map resolt amb PATCH-07 (namespace). La part d'Options/observers va amb COUP-03 (DEFERRED a `refactor/options-class`) | +| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE | Map resolt amb PATCH-07 (namespace). Options resolt amb COUP-03: estat mutable encapsulat darrere de l'API del namespace (anonymous ns + getters/setters). La part "observers" (notificacions automàtiques a Screen/Audio) queda WONTFIX justificat: invertiria la direcció de dependència `Director→Screen/Audio` que el projecte manté intencionadament | | CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 | | CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor | + +--- + +## Registro de commits — `refactor/options-class` + +Branca per a tancar els 4 ítems DEFERRED de `audit/game`. Base: `main` (32dcf26). + +- `` — COUP-03 + CONV-02: estat mutable d'`Options` mogut a anonymous ns + API de getters/setters; `global_inputs.cpp` i `director.cpp` adaptats. CONV-02 (part Options) tanca al pas. La part "observers" del audit es deixa WONTFIX (invertiria `Director→Screen/Audio`) +- `` — COUP-02: `MapInfo` plana injectada al `Player` (només Player; momia/roca/loadlevel resten amb `Map::*` global, com diu la nota original del audit) +- `` — PATCH-01: `tryMove(dx, dy, collisionFn)` a `MovingSprite` amb hook virtual `collisionBox()`; `Player::update` deixa de fer `update + revertir + reaplicar` diff --git a/source/core/input/global_inputs.cpp b/source/core/input/global_inputs.cpp index 35b3120..42fd0d8 100644 --- a/source/core/input/global_inputs.cpp +++ b/source/core/input/global_inputs.cpp @@ -42,83 +42,86 @@ namespace GlobalInputs { } void onDecZoom() { - if (Options::window.fullscreen) { return; } - if (Options::window.zoom <= MIN_ZOOM) { return; } - Options::window.zoom--; - Screen::get().setWindowZoom(Options::window.zoom); - Options::saveToFile(); - Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom)); + if (Options::window().fullscreen) { return; } + if (Options::window().zoom <= MIN_ZOOM) { return; } + const int NEW_ZOOM = Options::window().zoom - 1; + Options::setWindowZoom(NEW_ZOOM); + Screen::get().setWindowZoom(NEW_ZOOM); + Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM)); } void onIncZoom() { - if (Options::window.fullscreen) { return; } - if (Options::window.zoom >= MAX_ZOOM) { return; } - Options::window.zoom++; - Screen::get().setWindowZoom(Options::window.zoom); - Options::saveToFile(); - Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom)); + if (Options::window().fullscreen) { return; } + if (Options::window().zoom >= MAX_ZOOM) { return; } + const int NEW_ZOOM = Options::window().zoom + 1; + Options::setWindowZoom(NEW_ZOOM); + Screen::get().setWindowZoom(NEW_ZOOM); + Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM)); } void onFullscreen() { - Options::window.fullscreen = !Options::window.fullscreen; - Screen::get().setFullscreen(Options::window.fullscreen); - Options::saveToFile(); - notifyLocale(Options::window.fullscreen ? "input.fullscreen_on" : "input.fullscreen_off"); + const bool NEW_FS = !Options::window().fullscreen; + Options::setWindowFullscreen(NEW_FS); + Screen::get().setFullscreen(NEW_FS); + notifyLocale(NEW_FS ? "input.fullscreen_on" : "input.fullscreen_off"); } void onToggleVSync() { - Options::video.vsync = !Options::video.vsync; - Screen::get().setVSync(Options::video.vsync); - Options::saveToFile(); - notifyLocale(Options::video.vsync ? "input.vsync_on" : "input.vsync_off"); + const bool NEW_VSYNC = !Options::video().vsync; + Options::setVideoVSync(NEW_VSYNC); + Screen::get().setVSync(NEW_VSYNC); + notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off"); } void onToggleShader() { - Options::video.shader.enabled = !Options::video.shader.enabled; + const bool NEW_ENABLED = !Options::video().shader.enabled; + Options::setShaderEnabled(NEW_ENABLED); reapplyShader(); - Options::saveToFile(); - notifyLocale(Options::video.shader.enabled ? "input.shader_on" : "input.shader_off"); + notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off"); } void onNextShader() { - Options::video.shader.current_shader = (Options::video.shader.current_shader == "crtpi") ? "postfx" : "crtpi"; + const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi"; + Options::setActiveShaderName(NEW_NAME); reapplyShader(); - Options::saveToFile(); - Notification::notify(Locale::get().text("input.shader") + ": " + Options::video.shader.current_shader); + Notification::notify(Locale::get().text("input.shader") + ": " + NEW_NAME); } // Busca l'entrada actual al vector de presets i passa a la següent // (wrap-around). Si no en troba cap s'evita el canvi silenciosament. void onNextPreset() { - const bool IS_CRTPI = Options::video.shader.current_shader == "crtpi"; + const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi"; + std::string new_preset; if (IS_CRTPI) { - if (Options::crtpi_presets.empty()) { return; } - auto it = std::ranges::find_if(Options::crtpi_presets, - [](const auto& preset) { return preset.name == Options::video.shader.current_crtpi_preset; }); - std::size_t idx = (it == Options::crtpi_presets.end()) ? 0 : std::distance(Options::crtpi_presets.begin(), it); - idx = (idx + 1) % Options::crtpi_presets.size(); - Options::video.shader.current_crtpi_preset = Options::crtpi_presets[idx].name; + const auto& presets = Options::crtpiPresets(); + if (presets.empty()) { return; } + auto it = std::ranges::find_if(presets, + [](const auto& preset) { return preset.name == Options::video().shader.current_crtpi_preset; }); + std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it); + idx = (idx + 1) % presets.size(); + new_preset = presets[idx].name; + Options::setCrtPiPreset(new_preset); } else { - if (Options::postfx_presets.empty()) { return; } - auto it = std::ranges::find_if(Options::postfx_presets, - [](const auto& preset) { return preset.name == Options::video.shader.current_postfx_preset; }); - std::size_t idx = (it == Options::postfx_presets.end()) ? 0 : std::distance(Options::postfx_presets.begin(), it); - idx = (idx + 1) % Options::postfx_presets.size(); - Options::video.shader.current_postfx_preset = Options::postfx_presets[idx].name; + const auto& presets = Options::postfxPresets(); + if (presets.empty()) { return; } + auto it = std::ranges::find_if(presets, + [](const auto& preset) { return preset.name == Options::video().shader.current_postfx_preset; }); + std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it); + idx = (idx + 1) % presets.size(); + new_preset = presets[idx].name; + Options::setPostFXPreset(new_preset); } reapplyShader(); - Options::saveToFile(); - const std::string& name = IS_CRTPI ? Options::video.shader.current_crtpi_preset : Options::video.shader.current_postfx_preset; - Notification::notify(Locale::get().text("input.preset") + ": " + name); + Notification::notify(Locale::get().text("input.preset") + ": " + new_preset); } void onNextScalingMode() { using Mode = Defaults::ScalingMode; - Options::video.scaling_mode = static_cast((static_cast(Options::video.scaling_mode) + 1) % 5); - Screen::get().setScalingMode(Options::video.scaling_mode); - Options::saveToFile(); + const auto NEW_MODE = static_cast((static_cast(Options::video().scaling_mode) + 1) % 5); + Options::setVideoScalingMode(NEW_MODE); + Screen::get().setScalingMode(NEW_MODE); const char* mode_key = nullptr; - switch (Options::video.scaling_mode) { + switch (NEW_MODE) { case Mode::DISABLED: mode_key = "input.scaling_disabled"; break; @@ -139,14 +142,12 @@ namespace GlobalInputs { } void onCycleRenderInfo() { - const int NEXT = (static_cast(Options::video.render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT; - Options::video.render_info_position = static_cast(NEXT); - Options::saveToFile(); + const int NEXT = (static_cast(Options::video().render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT; + Options::setVideoRenderInfoPosition(static_cast(NEXT)); } void onToggleRenderTime() { - Options::video.render_info_show_time = !Options::video.render_info_show_time; - Options::saveToFile(); + Options::setVideoRenderInfoShowTime(!Options::video().render_info_show_time); } void onExit() { diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 89b6190..c8503d4 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -61,7 +61,7 @@ Director::Director() { // Traduccions per a tots els textos visibles. Si el fitxer no existeix // o hi ha un error de parse, Locale::text() caurà a retornar la clau. - Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale + ".yaml"); + Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale() + ".yaml"); // Hotkeys globals. GlobalInputs::update() les consumirà a cada frame. KeyConfig::get().load("data/input/keys.yaml"); diff --git a/source/game/options.cpp b/source/game/options.cpp index bca24c4..9fb479c 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -14,6 +14,21 @@ namespace { + // --- Estat mutable (privat al TU) --- + // Encapsulat ací per impedir mutacions directes des de fora del namespace + // Options:: (decisió COUP-03 / docs/audit-game.md). Els callers llegeixen + // via getters const-ref i muten via setters específics que persisteixen. + std::string g_config_version; + std::string g_config_file_path; + std::string g_postfx_file_path; + std::string g_crtpi_file_path; + std::string g_locale{Defaults::I18n::LOCALE}; + Options::Window g_window{}; + Options::Video g_video{}; + Options::Audio g_audio{}; + std::vector g_postfx_presets{}; + std::vector g_crtpi_presets{}; + // Taules enum ↔ string per a la serialització a YAML. La primera entrada // és el fallback quan l'enum no té nom conegut o el text no casa amb // cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen. @@ -93,40 +108,40 @@ namespace { void parseWindow(const fkyaml::node& yaml) { if (!yaml.contains("window")) { return; } const auto& node = yaml["window"]; - readField(node, "zoom", Options::window.zoom); - readField(node, "fullscreen", Options::window.fullscreen); + readField(node, "zoom", g_window.zoom); + readField(node, "fullscreen", g_window.fullscreen); } void parseGpu(const fkyaml::node& video_node) { if (!video_node.contains("gpu")) { return; } const auto& node = video_node["gpu"]; - readField(node, "acceleration", Options::video.gpu.acceleration); - readField(node, "preferred_driver", Options::video.gpu.preferred_driver); + readField(node, "acceleration", g_video.gpu.acceleration); + readField(node, "preferred_driver", g_video.gpu.preferred_driver); } void parseShader(const fkyaml::node& video_node) { if (!video_node.contains("shader")) { return; } const auto& node = video_node["shader"]; - readField(node, "enabled", Options::video.shader.enabled); - readField(node, "current_shader", Options::video.shader.current_shader); - readField(node, "current_postfx_preset", Options::video.shader.current_postfx_preset); - readField(node, "current_crtpi_preset", Options::video.shader.current_crtpi_preset); + readField(node, "enabled", g_video.shader.enabled); + readField(node, "current_shader", g_video.shader.current_shader); + readField(node, "current_postfx_preset", g_video.shader.current_postfx_preset); + readField(node, "current_crtpi_preset", g_video.shader.current_crtpi_preset); } void parseVideo(const fkyaml::node& yaml) { if (!yaml.contains("video")) { return; } const auto& node = yaml["video"]; - readField(node, "vsync", Options::video.vsync); + readField(node, "vsync", g_video.vsync); if (node.contains("scaling_mode")) { - Options::video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value()); + g_video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value()); } if (node.contains("texture_filter")) { - Options::video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value()); + g_video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value()); } if (node.contains("render_info_position")) { - Options::video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value()); + g_video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value()); } - readField(node, "render_info_show_time", Options::video.render_info_show_time); + readField(node, "render_info_show_time", g_video.render_info_show_time); parseGpu(node); parseShader(node); } @@ -134,22 +149,22 @@ namespace { void parseMusic(const fkyaml::node& audio_node) { if (!audio_node.contains("music")) { return; } const auto& node = audio_node["music"]; - readField(node, "enabled", Options::audio.music.enabled); - readField(node, "volume", Options::audio.music.volume); + readField(node, "enabled", g_audio.music.enabled); + readField(node, "volume", g_audio.music.volume); } void parseSound(const fkyaml::node& audio_node) { if (!audio_node.contains("sound")) { return; } const auto& node = audio_node["sound"]; - readField(node, "enabled", Options::audio.sound.enabled); - readField(node, "volume", Options::audio.sound.volume); + readField(node, "enabled", g_audio.sound.enabled); + readField(node, "volume", g_audio.sound.volume); } void parseAudio(const fkyaml::node& yaml) { if (!yaml.contains("audio")) { return; } const auto& node = yaml["audio"]; - readField(node, "enabled", Options::audio.enabled); - readField(node, "volume", Options::audio.volume); + readField(node, "enabled", g_audio.enabled); + readField(node, "volume", g_audio.volume); parseMusic(node); parseSound(node); } @@ -423,14 +438,65 @@ namespace { namespace Options { + // --- Getters de l'estat --- + auto window() -> const Window& { return g_window; } + auto video() -> const Video& { return g_video; } + auto audio() -> const Audio& { return g_audio; } + auto locale() -> const std::string& { return g_locale; } + auto configVersion() -> const std::string& { return g_config_version; } + auto postfxPresets() -> const std::vector& { return g_postfx_presets; } + auto crtpiPresets() -> const std::vector& { return g_crtpi_presets; } + + // --- Setters: muten + persisteixen --- + void setWindowZoom(int value) { + g_window.zoom = value; + saveToFile(); + } + void setWindowFullscreen(bool value) { + g_window.fullscreen = value; + saveToFile(); + } + void setVideoVSync(bool value) { + g_video.vsync = value; + saveToFile(); + } + void setVideoScalingMode(Defaults::ScalingMode value) { + g_video.scaling_mode = value; + saveToFile(); + } + void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value) { + g_video.render_info_position = value; + saveToFile(); + } + void setVideoRenderInfoShowTime(bool value) { + g_video.render_info_show_time = value; + saveToFile(); + } + void setShaderEnabled(bool value) { + g_video.shader.enabled = value; + saveToFile(); + } + void setActiveShaderName(const std::string& value) { + g_video.shader.current_shader = value; + saveToFile(); + } + void setPostFXPreset(const std::string& value) { + g_video.shader.current_postfx_preset = value; + saveToFile(); + } + void setCrtPiPreset(const std::string& value) { + g_video.shader.current_crtpi_preset = value; + saveToFile(); + } + void setConfigFile(const std::string& path) { - config_file_path = path; + g_config_file_path = path; } auto loadFromFile() -> bool { - if (config_file_path.empty()) { return false; } + if (g_config_file_path.empty()) { return false; } - const std::string CONTENT = readFileOrEmpty(config_file_path); + const std::string CONTENT = readFileOrEmpty(g_config_file_path); if (CONTENT.empty()) { // Primera execució: creem el fitxer amb els valors per defecte. return saveToFile(); @@ -449,12 +515,12 @@ namespace Options { if (file_version != Texts::VERSION) { std::cout << "Options: config.yaml versió \"" << file_version << "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n"; - backupFile(config_file_path); + backupFile(g_config_file_path); return saveToFile(); } - config_version = file_version; + g_config_version = file_version; - if (yaml.contains("locale")) { Options::locale = yaml["locale"].get_value(); } + if (yaml.contains("locale")) { g_locale = yaml["locale"].get_value(); } parseWindow(yaml); parseVideo(yaml); parseAudio(yaml); @@ -462,84 +528,84 @@ namespace Options { } catch (const std::exception& e) { std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n'; std::cout << "Options: reescrivint el fitxer amb defaults.\n"; - backupFile(config_file_path); + backupFile(g_config_file_path); return saveToFile(); } } auto saveToFile() -> bool { - if (config_file_path.empty()) { return false; } + if (g_config_file_path.empty()) { return false; } - config_version = Texts::VERSION; + g_config_version = Texts::VERSION; std::ostringstream out; out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n"; - out << "version: \"" << config_version << "\"\n"; - out << "locale: \"" << locale << "\"\n\n"; + out << "version: \"" << g_config_version << "\"\n"; + out << "locale: \"" << g_locale << "\"\n\n"; out << "window:\n"; - out << " zoom: " << window.zoom << "\n"; - out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n"; + out << " zoom: " << g_window.zoom << "\n"; + out << " fullscreen: " << (g_window.fullscreen ? "true" : "false") << "\n\n"; out << "video:\n"; - out << " vsync: " << (video.vsync ? "true" : "false") << "\n"; - out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, video.scaling_mode) << "\"\n"; - out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, video.texture_filter) << "\"\n"; - out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, video.render_info_position) << "\"\n"; - out << " render_info_show_time: " << (video.render_info_show_time ? "true" : "false") << "\n"; + out << " vsync: " << (g_video.vsync ? "true" : "false") << "\n"; + out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, g_video.scaling_mode) << "\"\n"; + out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, g_video.texture_filter) << "\"\n"; + out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, g_video.render_info_position) << "\"\n"; + out << " render_info_show_time: " << (g_video.render_info_show_time ? "true" : "false") << "\n"; out << " gpu:\n"; - out << " acceleration: " << (video.gpu.acceleration ? "true" : "false") << "\n"; - out << " preferred_driver: \"" << video.gpu.preferred_driver << "\"\n"; + out << " acceleration: " << (g_video.gpu.acceleration ? "true" : "false") << "\n"; + out << " preferred_driver: \"" << g_video.gpu.preferred_driver << "\"\n"; out << " shader:\n"; - out << " enabled: " << (video.shader.enabled ? "true" : "false") << "\n"; - out << " current_shader: \"" << video.shader.current_shader << "\"\n"; - out << " current_postfx_preset: \"" << video.shader.current_postfx_preset << "\"\n"; - out << " current_crtpi_preset: \"" << video.shader.current_crtpi_preset << "\"\n\n"; + out << " enabled: " << (g_video.shader.enabled ? "true" : "false") << "\n"; + out << " current_shader: \"" << g_video.shader.current_shader << "\"\n"; + out << " current_postfx_preset: \"" << g_video.shader.current_postfx_preset << "\"\n"; + out << " current_crtpi_preset: \"" << g_video.shader.current_crtpi_preset << "\"\n\n"; out << "audio:\n"; - out << " enabled: " << (audio.enabled ? "true" : "false") << "\n"; - out << " volume: " << audio.volume << "\n"; + out << " enabled: " << (g_audio.enabled ? "true" : "false") << "\n"; + out << " volume: " << g_audio.volume << "\n"; out << " music:\n"; - out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n"; - out << " volume: " << audio.music.volume << "\n"; + out << " enabled: " << (g_audio.music.enabled ? "true" : "false") << "\n"; + out << " volume: " << g_audio.music.volume << "\n"; out << " sound:\n"; - out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n"; - out << " volume: " << audio.sound.volume << "\n"; + out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n"; + out << " volume: " << g_audio.sound.volume << "\n"; - return writeFile(config_file_path, out.str()); + return writeFile(g_config_file_path, out.str()); } auto buildScreenConfig() -> Screen::Config { return { - .zoom = window.zoom, - .fullscreen = window.fullscreen, - .vsync = video.vsync, - .scaling_mode = video.scaling_mode, - .texture_filter = video.texture_filter, - .gpu_acceleration = video.gpu.acceleration, - .preferred_driver = video.gpu.preferred_driver, + .zoom = g_window.zoom, + .fullscreen = g_window.fullscreen, + .vsync = g_video.vsync, + .scaling_mode = g_video.scaling_mode, + .texture_filter = g_video.texture_filter, + .gpu_acceleration = g_video.gpu.acceleration, + .preferred_driver = g_video.gpu.preferred_driver, }; } auto buildAudioConfig() -> ::Audio::Config { return { - .enabled = audio.enabled, - .volume = audio.volume, - .music_enabled = audio.music.enabled, - .music_volume = audio.music.volume, - .sound_enabled = audio.sound.enabled, - .sound_volume = audio.sound.volume, + .enabled = g_audio.enabled, + .volume = g_audio.volume, + .music_enabled = g_audio.music.enabled, + .music_volume = g_audio.music.volume, + .sound_enabled = g_audio.sound.enabled, + .sound_volume = g_audio.sound.volume, }; } void setPostFXFile(const std::string& path) { - postfx_file_path = path; + g_postfx_file_path = path; } auto loadPostFXFromFile() -> bool { return loadPresetsFromFile( - postfx_file_path, - postfx_presets, + g_postfx_file_path, + g_postfx_presets, &defaultPostFXPresets, &parsePostFXPreset, &savePostFXToFile, @@ -548,21 +614,21 @@ namespace Options { auto savePostFXToFile() -> bool { return savePresetsToFile( - postfx_file_path, - postfx_presets, + g_postfx_file_path, + g_postfx_presets, &writePostFXPreset, "Presets PostFX per a AEE 2026. Editable a mà.", "Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset."); } void setCrtPiFile(const std::string& path) { - crtpi_file_path = path; + g_crtpi_file_path = path; } auto loadCrtPiFromFile() -> bool { return loadPresetsFromFile( - crtpi_file_path, - crtpi_presets, + g_crtpi_file_path, + g_crtpi_presets, &defaultCrtPiPresets, &parseCrtPiPreset, &saveCrtPiToFile, @@ -571,29 +637,29 @@ namespace Options { auto saveCrtPiToFile() -> bool { return savePresetsToFile( - crtpi_file_path, - crtpi_presets, + g_crtpi_file_path, + g_crtpi_presets, &writeCrtPiPreset, "Presets CRT-Pi per a AEE 2026. Editable a mà.", "Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset."); } auto buildActiveShader() -> Rendering::ShaderType { - if (video.shader.current_shader == "crtpi") { + if (g_video.shader.current_shader == "crtpi") { return Rendering::ShaderType::CRTPI; } return Rendering::ShaderType::POSTFX; } auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame { - const std::string& active_preset = (video.shader.current_shader == "crtpi") - ? video.shader.current_crtpi_preset - : video.shader.current_postfx_preset; + const std::string& active_preset = (g_video.shader.current_shader == "crtpi") + ? g_video.shader.current_crtpi_preset + : g_video.shader.current_postfx_preset; return { - .target_position = video.render_info_position, - .show_time = video.render_info_show_time, - .shader_enabled = video.shader.enabled, - .shader_name = video.shader.current_shader, + .target_position = g_video.render_info_position, + .show_time = g_video.render_info_show_time, + .shader_enabled = g_video.shader.enabled, + .shader_name = g_video.shader.current_shader, .shader_preset = active_preset, .text_color = text_color, .shadow_color = shadow_color, @@ -603,12 +669,12 @@ namespace Options { auto buildCrtPiParams() -> Rendering::CrtPiParams { // Si el shader està deshabilitat, retornem defaults (el dispatch anirà per // PostFX passthrough, però sent nets per si algú consulta aquests params). - if (!video.shader.enabled) { return {}; } + if (!g_video.shader.enabled) { return {}; } - const auto IT = std::ranges::find_if(crtpi_presets, - [](const CrtPiPreset& preset) { return preset.name == video.shader.current_crtpi_preset; }); - if (IT == crtpi_presets.end()) { - std::cout << "Options: preset CrtPi \"" << video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n"; + const auto IT = std::ranges::find_if(g_crtpi_presets, + [](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; }); + if (IT == g_crtpi_presets.end()) { + std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n"; return {}; } return { @@ -631,12 +697,12 @@ namespace Options { auto buildPostFXParams() -> Rendering::PostFXParams { // Si el shader està deshabilitat, retornem params a zero → passthrough. - if (!video.shader.enabled) { return {}; } + if (!g_video.shader.enabled) { return {}; } - const auto IT = std::ranges::find_if(postfx_presets, - [](const PostFXPreset& preset) { return preset.name == video.shader.current_postfx_preset; }); - if (IT == postfx_presets.end()) { - std::cout << "Options: preset PostFX \"" << video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n"; + const auto IT = std::ranges::find_if(g_postfx_presets, + [](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; }); + if (IT == g_postfx_presets.end()) { + std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n"; return {}; } return { diff --git a/source/game/options.hpp b/source/game/options.hpp index 2590a74..028e137 100644 --- a/source/game/options.hpp +++ b/source/game/options.hpp @@ -13,6 +13,15 @@ // del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats // a través dels build*Config(), de manera que no queden acoblats a aquest // namespace ni al format concret del fitxer. +// +// L'estat mutable viu en un anonymous namespace dins d'`options.cpp`: els +// callers el llegeixen via getters const-ref (`window()`, `video()`, ...) i +// el muten via setters específics que persisteixen a config.yaml. Els +// setters NO notifiquen els subsistemes (Screen/Audio): el caller (típic. +// `core/input/global_inputs.cpp`) crida després el setter del subsistema. +// Aquesta asimetria preserva la direcció `Director → Screen/Audio` i evita +// que Options aprenga del layout dels subsistemes (decisió WONTFIX d'aquest +// refactor: veure docs/audit-game.md → COUP-03). namespace Options { struct Window { @@ -95,20 +104,30 @@ namespace Options { Sound sound{}; }; - // --- Estat global (inline vars per a que una sola definició viva a tot - // el programa sense necessitar un .cpp extra per a cada una) --- - inline std::string config_version{}; - inline std::string config_file_path{}; - inline std::string postfx_file_path{}; - inline std::string crtpi_file_path{}; - inline std::string locale{Defaults::I18n::LOCALE}; - inline Window window{}; - inline Video video{}; - inline Audio audio{}; - inline std::vector postfx_presets{}; - inline std::vector crtpi_presets{}; + // --- Getters de l'estat (read-only) --- + [[nodiscard]] auto window() -> const Window&; + [[nodiscard]] auto video() -> const Video&; + [[nodiscard]] auto audio() -> const Audio&; + [[nodiscard]] auto locale() -> const std::string&; + [[nodiscard]] auto configVersion() -> const std::string&; + [[nodiscard]] auto postfxPresets() -> const std::vector&; + [[nodiscard]] auto crtpiPresets() -> const std::vector&; - // --- API pública --- + // --- Setters: muten + persisteixen a config.yaml --- + // No notifiquen els subsistemes; el caller ha de propagar el canvi al + // subsistema corresponent (p.ex. Screen::setWindowZoom) per separat. + void setWindowZoom(int value); + void setWindowFullscreen(bool value); + void setVideoVSync(bool value); + void setVideoScalingMode(Defaults::ScalingMode value); + void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value); + void setVideoRenderInfoShowTime(bool value); + void setShaderEnabled(bool value); + void setActiveShaderName(const std::string& value); // "postfx" | "crtpi" + void setPostFXPreset(const std::string& value); + void setCrtPiPreset(const std::string& value); + + // --- API pública: càrrega/desament de fitxers --- void setConfigFile(const std::string& path); auto loadFromFile() -> bool; auto saveToFile() -> bool;