diff --git a/CMakeLists.txt b/CMakeLists.txt index ff7d0e6..7c0b8b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,7 @@ set(APP_SOURCES source/core/resources/resource_helper.cpp source/core/resources/resource_cache.cpp source/utils/utils.cpp + source/game/options.cpp source/game/scenes/game_scene.cpp source/game/entities/player.cpp ) diff --git a/source/core/rendering/screen.cpp b/source/core/rendering/screen.cpp index bb7ab4a..5406b96 100644 --- a/source/core/rendering/screen.cpp +++ b/source/core/rendering/screen.cpp @@ -8,7 +8,6 @@ #include #include -#include "utils/defaults.hpp" #include "utils/defines.hpp" namespace { @@ -48,12 +47,41 @@ Screen::Screen() std::fprintf(stderr, "SDL_InitSubSystem(VIDEO) failed: %s\n", SDL_GetError()); std::exit(EXIT_FAILURE); } + // Els recursos SDL (finestra, renderer, textura) es creen a configure(). +} - const int WIN_W = GameCanvas::WIDTH * Defaults::Window::ZOOM; - const int WIN_H = GameCanvas::HEIGHT * Defaults::Window::ZOOM; +Screen::~Screen() { + destroySdl(); + SDL_QuitSubSystem(SDL_INIT_VIDEO); +} + +void Screen::configure(const Config& config) { + destroySdl(); + config_ = config; + createSdl(); +} + +void Screen::destroySdl() { + if (game_texture_ != nullptr) { + SDL_DestroyTexture(game_texture_); + game_texture_ = nullptr; + } + if (renderer_ != nullptr) { + SDL_DestroyRenderer(renderer_); + renderer_ = nullptr; + } + if (window_ != nullptr) { + SDL_DestroyWindow(window_); + window_ = nullptr; + } +} + +void Screen::createSdl() { + const int WIN_W = GameCanvas::WIDTH * config_.zoom; + const int WIN_H = GameCanvas::HEIGHT * config_.zoom; Uint32 win_flags = SDL_WINDOW_RESIZABLE; - if (Defaults::Window::FULLSCREEN) { win_flags |= SDL_WINDOW_FULLSCREEN; } + if (config_.fullscreen) { win_flags |= SDL_WINDOW_FULLSCREEN; } window_ = SDL_CreateWindow(Texts::WINDOW_TITLE, WIN_W, WIN_H, win_flags); if (window_ == nullptr) { @@ -67,8 +95,8 @@ Screen::Screen() std::exit(EXIT_FAILURE); } - SDL_SetRenderVSync(renderer_, Defaults::Video::VSYNC ? 1 : 0); - SDL_SetRenderLogicalPresentation(renderer_, GameCanvas::WIDTH, GameCanvas::HEIGHT, toSdlPresentation(Defaults::Video::SCALING_MODE)); + SDL_SetRenderVSync(renderer_, config_.vsync ? 1 : 0); + SDL_SetRenderLogicalPresentation(renderer_, GameCanvas::WIDTH, GameCanvas::HEIGHT, toSdlPresentation(config_.scaling_mode)); // STREAMING: el framebuffer ARGB del joc s'actualitza per CPU cada frame // i s'envia a la GPU via SDL_UpdateTexture en present(). @@ -77,14 +105,7 @@ Screen::Screen() std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError()); std::exit(EXIT_FAILURE); } - SDL_SetTextureScaleMode(game_texture_, toSdlScaleMode(Defaults::Video::TEXTURE_FILTER)); -} - -Screen::~Screen() { - if (game_texture_ != nullptr) { SDL_DestroyTexture(game_texture_); } - if (renderer_ != nullptr) { SDL_DestroyRenderer(renderer_); } - if (window_ != nullptr) { SDL_DestroyWindow(window_); } - SDL_QuitSubSystem(SDL_INIT_VIDEO); + SDL_SetTextureScaleMode(game_texture_, toSdlScaleMode(config_.texture_filter)); } void Screen::clear(std::uint8_t r, std::uint8_t g, std::uint8_t b) { diff --git a/source/core/rendering/screen.hpp b/source/core/rendering/screen.hpp index 57403a8..c306ee6 100644 --- a/source/core/rendering/screen.hpp +++ b/source/core/rendering/screen.hpp @@ -5,8 +5,20 @@ #include #include +#include "utils/defaults.hpp" + class Screen { public: + // Configuració typada de Screen. L'omple el Director des d'Options, + // de manera que Screen no coneix YAML ni la capa Options directament. + struct Config { + int zoom{Defaults::Window::ZOOM}; + bool fullscreen{Defaults::Window::FULLSCREEN}; + bool vsync{Defaults::Video::VSYNC}; + Defaults::ScalingMode scaling_mode{Defaults::Video::SCALING_MODE}; + Defaults::TextureFilter texture_filter{Defaults::Video::TEXTURE_FILTER}; + }; + // Meyers singleton: la destrucció ocorre durant la termianció estàtica // (LIFO respecte a Resource::Cache). El SDL_Quit s'ha de registrar via // std::atexit ABANS de la primera crida a get(); així Screen::~Screen @@ -18,6 +30,10 @@ class Screen { auto operator=(const Screen&) -> Screen& = delete; auto operator=(Screen&&) -> Screen& = delete; + // Crea (o recrea) finestra, renderer i textura a partir de la config. + // Imprescindible cridar-lo abans de qualsevol clear()/present(). + void configure(const Config& config); + void clear(std::uint8_t r = 0, std::uint8_t g = 0, std::uint8_t b = 0); void present(); @@ -30,6 +46,11 @@ class Screen { Screen(); ~Screen(); + void destroySdl(); + void createSdl(); + + Config config_{}; + SDL_Window* window_{nullptr}; SDL_Renderer* renderer_{nullptr}; SDL_Texture* game_texture_{nullptr}; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 6cdd72a..a9ccac8 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -12,6 +14,7 @@ #include "core/resources/resource_cache.hpp" #include "core/scene/scene.hpp" #include "core/system/event_buffer.hpp" +#include "game/options.hpp" #include "game/scenes/game_scene.hpp" Director::Director() { @@ -26,10 +29,18 @@ Director::Director() { // amb SDL ja apagat → caos de destrucció clàssic. std::atexit(+[] { SDL_Quit(); }); + // Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega + // les opcions; si no existeix config.yaml el crea amb els defaults. + createSystemFolder(); + Options::setConfigFile(system_folder_ + "config.yaml"); + if (!Options::loadFromFile()) { + std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n'; + } + // Forcem la construcció dels singletons en l'ordre desitjat: primer // Screen (necessari per a renderitzar), després Cache. La destrucció // estàtica anirà LIFO: Cache primer, Screen últim. - (void)Screen::get(); + Screen::get().configure(Options::buildScreenConfig()); (void)Resource::Cache::get(); current_scene_ = std::make_unique(); @@ -41,6 +52,27 @@ Director::~Director() { // SDL_Quit corre via atexit; els singletons via destrucció estàtica. } +void Director::createSystemFolder() { + // Volem la config a $XDG_CONFIG_HOME/jailgames/aee_2026/ (defecte + // ~/.config/jailgames/aee_2026/). SDL_GetPrefPath apunta a XDG_DATA_HOME + // (.local/share), que és el lloc per a dades, no per a configuració. + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + std::string base; + if (xdg != nullptr && *xdg != '\0') { + base = xdg; + } else { + const char* home = std::getenv("HOME"); + base = (home != nullptr) ? std::string(home) + "/.config" : "."; + } + system_folder_ = base + "/jailgames/aee_2026/"; + + std::error_code error; + std::filesystem::create_directories(system_folder_, error); + if (error) { + std::cerr << "Director: no s'ha pogut crear " << system_folder_ << ": " << error.message() << '\n'; + } +} + void Director::processEvents() { const bool QUIT_REQUESTED = std::ranges::any_of( EventBuffer::events, diff --git a/source/core/system/director.hpp b/source/core/system/director.hpp index 771b7d4..8eec87d 100644 --- a/source/core/system/director.hpp +++ b/source/core/system/director.hpp @@ -4,6 +4,7 @@ #include #include +#include class Scene; @@ -15,9 +16,11 @@ class Director { auto iterate() -> SDL_AppResult; private: + std::string system_folder_; std::unique_ptr current_scene_; std::uint64_t last_ticks_ms_{0}; + void createSystemFolder(); void processEvents(); void switchSceneIfRequested(); }; diff --git a/source/game/options.cpp b/source/game/options.cpp new file mode 100644 index 0000000..376e20b --- /dev/null +++ b/source/game/options.cpp @@ -0,0 +1,183 @@ +#include "game/options.hpp" + +#include +#include +#include +#include + +#include "external/fkyaml_node.hpp" +#include "utils/defines.hpp" + +namespace { + + auto scalingModeToString(Defaults::ScalingMode mode) -> const char* { + switch (mode) { + case Defaults::ScalingMode::DISABLED: + return "disabled"; + case Defaults::ScalingMode::STRETCH: + return "stretch"; + case Defaults::ScalingMode::LETTERBOX: + return "letterbox"; + case Defaults::ScalingMode::OVERSCAN: + return "overscan"; + case Defaults::ScalingMode::INTEGER: + return "integer"; + } + return "letterbox"; + } + + auto scalingModeFromString(const std::string& text) -> Defaults::ScalingMode { + if (text == "disabled") { return Defaults::ScalingMode::DISABLED; } + if (text == "stretch") { return Defaults::ScalingMode::STRETCH; } + if (text == "overscan") { return Defaults::ScalingMode::OVERSCAN; } + if (text == "integer") { return Defaults::ScalingMode::INTEGER; } + return Defaults::ScalingMode::LETTERBOX; + } + + auto filterToString(Defaults::TextureFilter filter) -> const char* { + return filter == Defaults::TextureFilter::LINEAR ? "linear" : "nearest"; + } + + auto filterFromString(const std::string& text) -> Defaults::TextureFilter { + return text == "linear" ? Defaults::TextureFilter::LINEAR : Defaults::TextureFilter::NEAREST; + } + + auto readFileOrEmpty(const std::string& path) -> std::string { + std::ifstream file(path); + if (!file) { return {}; } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); + } + + auto writeFile(const std::string& path, const std::string& content) -> bool { + std::ofstream file(path, std::ios::binary); + if (!file) { return false; } + file.write(content.data(), static_cast(content.size())); + return file.good(); + } + + void parseWindow(const fkyaml::node& yaml) { + if (!yaml.contains("window")) { return; } + const auto& node = yaml["window"]; + if (node.contains("zoom")) { Options::window.zoom = node["zoom"].get_value(); } + if (node.contains("fullscreen")) { Options::window.fullscreen = node["fullscreen"].get_value(); } + } + + void parseVideo(const fkyaml::node& yaml) { + if (!yaml.contains("video")) { return; } + const auto& node = yaml["video"]; + if (node.contains("vsync")) { Options::video.vsync = node["vsync"].get_value(); } + if (node.contains("scaling_mode")) { + Options::video.scaling_mode = scalingModeFromString(node["scaling_mode"].get_value()); + } + if (node.contains("texture_filter")) { + Options::video.texture_filter = filterFromString(node["texture_filter"].get_value()); + } + } + + void parseMusic(const fkyaml::node& audio_node) { + if (!audio_node.contains("music")) { return; } + const auto& node = audio_node["music"]; + if (node.contains("enabled")) { Options::audio.music.enabled = node["enabled"].get_value(); } + if (node.contains("volume")) { Options::audio.music.volume = node["volume"].get_value(); } + } + + void parseSound(const fkyaml::node& audio_node) { + if (!audio_node.contains("sound")) { return; } + const auto& node = audio_node["sound"]; + if (node.contains("enabled")) { Options::audio.sound.enabled = node["enabled"].get_value(); } + if (node.contains("volume")) { Options::audio.sound.volume = node["volume"].get_value(); } + } + + void parseAudio(const fkyaml::node& yaml) { + if (!yaml.contains("audio")) { return; } + const auto& node = yaml["audio"]; + if (node.contains("enabled")) { Options::audio.enabled = node["enabled"].get_value(); } + if (node.contains("volume")) { Options::audio.volume = node["volume"].get_value(); } + parseMusic(node); + parseSound(node); + } + +} // namespace + +namespace Options { + + void setConfigFile(const std::string& path) { + config_file_path = path; + } + + auto loadFromFile() -> bool { + if (config_file_path.empty()) { return false; } + + const std::string CONTENT = readFileOrEmpty(config_file_path); + if (CONTENT.empty()) { + // Primera execució: creem el fitxer amb els valors per defecte. + return saveToFile(); + } + + try { + auto yaml = fkyaml::node::deserialize(CONTENT); + + std::string file_version; + if (yaml.contains("version")) { + file_version = yaml["version"].get_value(); + } + // Sense migració per ara: si la versió no coincideix, reescrivim + // amb els defaults de la versió nova. + if (file_version != Texts::VERSION) { return saveToFile(); } + config_version = file_version; + + parseWindow(yaml); + parseVideo(yaml); + parseAudio(yaml); + return true; + } catch (const std::exception& e) { + std::cerr << "Options: error parsejant config.yaml: " << e.what() << '\n'; + std::cerr << "Options: reescrivint el fitxer amb defaults.\n"; + return saveToFile(); + } + } + + auto saveToFile() -> bool { + if (config_file_path.empty()) { return false; } + + config_version = Texts::VERSION; + + std::ostringstream out; + out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n"; + out << "version: \"" << config_version << "\"\n\n"; + + out << "window:\n"; + out << " zoom: " << window.zoom << "\n"; + out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n"; + + out << "video:\n"; + out << " vsync: " << (video.vsync ? "true" : "false") << "\n"; + out << " scaling_mode: \"" << scalingModeToString(video.scaling_mode) << "\"\n"; + out << " texture_filter: \"" << filterToString(video.texture_filter) << "\"\n\n"; + + out << "audio:\n"; + out << " enabled: " << (audio.enabled ? "true" : "false") << "\n"; + out << " volume: " << audio.volume << "\n"; + out << " music:\n"; + out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n"; + out << " volume: " << audio.music.volume << "\n"; + out << " sound:\n"; + out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n"; + out << " volume: " << audio.sound.volume << "\n"; + + return writeFile(config_file_path, out.str()); + } + + auto buildScreenConfig() -> Screen::Config { + Screen::Config config; + config.zoom = window.zoom; + config.fullscreen = window.fullscreen; + config.vsync = video.vsync; + config.scaling_mode = video.scaling_mode; + config.texture_filter = video.texture_filter; + return config; + } + +} // namespace Options diff --git a/source/game/options.hpp b/source/game/options.hpp new file mode 100644 index 0000000..969ea7d --- /dev/null +++ b/source/game/options.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include "core/rendering/screen.hpp" +#include "utils/defaults.hpp" + +// Gestor de configuració del joc. És l'ÚNIC lloc que coneix YAML i la ruta +// 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. +namespace Options { + + struct Window { + int zoom{Defaults::Window::ZOOM}; + bool fullscreen{Defaults::Window::FULLSCREEN}; + }; + + struct Video { + bool vsync{Defaults::Video::VSYNC}; + Defaults::ScalingMode scaling_mode{Defaults::Video::SCALING_MODE}; + Defaults::TextureFilter texture_filter{Defaults::Video::TEXTURE_FILTER}; + }; + + struct Music { + bool enabled{Defaults::Audio::MUSIC_ENABLED}; + float volume{Defaults::Audio::MUSIC_VOLUME}; + }; + + struct Sound { + bool enabled{Defaults::Audio::SOUND_ENABLED}; + float volume{Defaults::Audio::SOUND_VOLUME}; + }; + + struct Audio { + bool enabled{Defaults::Audio::ENABLED}; + float volume{Defaults::Audio::VOLUME}; + Music music{}; + 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 Window window{}; + inline Video video{}; + inline Audio audio{}; + + // --- API pública --- + void setConfigFile(const std::string& path); + auto loadFromFile() -> bool; + auto saveToFile() -> bool; + + // --- Builders per als subsistemes --- + [[nodiscard]] auto buildScreenConfig() -> Screen::Config; + +} // namespace Options diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 1e49e58..3b8e53f 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -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.2.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.3.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {