#include "core/system/director.hpp" #include #include #include #include #include #include #include #include #include #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" #include "core/rendering/screen.hpp" #include "core/rendering/surface.hpp" #include "core/rendering/text.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "core/scene/scene.hpp" #include "core/system/event_buffer.hpp" #include "game/debug_config.hpp" #include "game/options.hpp" #include "game/scenes/game_scene.hpp" #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" Director::Director() { if (!SDL_Init(0)) { std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); } // Sistema de recursos: el pack viu al costat del binari (build/), el // regenera CMake a cada build. En Debug habilitem el fallback a disc // apuntant al source tree perquè editar YAML/GIF dins `data/` es vegi // sense re-packejar; en Release el pack és obligatori. std::string pack_file = "resources.pack"; if (const char* base = SDL_GetBasePath()) { pack_file = std::string(base) + "resources.pack"; #ifndef _DEBUG Resource::Helper::setDataRoot(base); #endif } #ifdef _DEBUG Resource::Helper::setDataRoot(AEE_SOURCE_DIR); constexpr bool ENABLE_FALLBACK = true; #else constexpr bool ENABLE_FALLBACK = false; #endif if (!Resource::Helper::initializeResourceSystem(pack_file, ENABLE_FALLBACK)) { std::fprintf(stderr, "Director: sistema de recursos no disponible; avortem\n"); std::exit(1); } // 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'; } // Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix // es crea amb presets per defecte (off, crt, ntsc). Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE); if (!Options::loadPostFXFromFile()) { std::cerr << "Director: no s'ha pogut carregar ni desar postfx.yaml.\n"; } // Presets del shader CRT-Pi (shaders/crtpi.yaml). Options::setCrtPiFile(system_folder_ + Defaults::Video::CRTPI_FILE); if (!Options::loadCrtPiFromFile()) { std::cerr << "Director: no s'ha pogut carregar ni desar crtpi.yaml.\n"; } // Screen es configura ara (subsystem + finestra + GPU/renderer); la seua // destrucció la fa aquest Director (veure ~Director). Res de static dtor. Screen::get().configure(Options::buildScreenConfig()); Screen::get().setActiveShader(Options::buildActiveShader()); Screen::get().applyPostFXParams(Options::buildPostFXParams()); Screen::get().applyCrtPiParams(Options::buildCrtPiParams()); (void)Resource::Cache::get(); // 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"); // Hotkeys globals. GlobalInputs::update() les consumirà a cada frame. KeyConfig::get().load("data/input/keys.yaml"); // Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els // overlays perquè tots els reaprofiten sense duplicar la lògica. Els // noms de font viuen a Defaults::UI::Font (un únic lloc per canviar-los). Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal"); const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif"; Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, Defaults::UI::Font::HUD_GIF); Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); const std::string PLACEHOLDER_FONT_GIF_KEY = std::string(Defaults::UI::Font::PLACEHOLDER_NAME) + ".gif"; Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, Defaults::UI::Font::PLACEHOLDER_GIF); Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); // Música de la piràmide 1 (primer i únic track de moment). Carreguem ací // perquè forma part del preload d'assets, com la font i la paleta. Resource::Cache::get().loadMusic("pyramid_1.ogg", "data/music/pyramid_1.ogg"); // Audio: singleton manual propietari d'un SDL audio device. Es destrueix // explícitament a ~Director abans que Resource::Cache (Meyers) buide els // 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(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); Notifier::get().init(hud_text, ui_palette); // Font dels placeholders (LOGO / TITLE / INTRO). És una font distinta a // la de HUD perquè els placeholders desapareixeran amb les seues escenes // 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(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(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. // // Release: sempre s'arranca a LOGO (el flux complet, el que veu el // jugador final). Debug: s'obri `debug.yaml`; si no existeix, el creem // amb `start_scene: GAME` perquè el dia a dia de gameplay no es mengi // els 15 s de placeholders. L'usuari pot editar-lo a mà per escollir // un punt d'entrada intermedi. #ifdef _DEBUG const auto DEBUG_YAML = system_folder_ + "debug.yaml"; DebugConfig::createDefaultIfMissing(DEBUG_YAML); const auto START = DebugConfig::resolveStartScene(DEBUG_YAML); #else const auto START = DebugConfig::StartScene::LOGO; #endif std::cout << "Director: start_scene=" << DebugConfig::toString(START) << '\n'; auto make_game = []() -> std::unique_ptr { return std::make_unique(); }; auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr { return std::make_unique(placeholder_text, make_game); }; auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr { return std::make_unique(placeholder_text, make_intro); }; switch (START) { case DebugConfig::StartScene::LOGO: current_scene_ = std::make_unique(placeholder_text, make_title); break; case DebugConfig::StartScene::TITLE: current_scene_ = make_title(); break; case DebugConfig::StartScene::INTRO: current_scene_ = make_intro(); break; case DebugConfig::StartScene::GAME: current_scene_ = make_game(); break; } last_ticks_ms_ = SDL_GetTicks(); } Director::~Director() { // Ordre imprescindible: primer l'escena (pot tenir recursos); després // alliberem els recursos d'àudio del Cache mentre SDL encara està viu // (en Meyers, el Cache es destrueix després de SDL_Quit i els streams // dels JA_Music_t quedarien invalidats); després Audio (tanca l'audio // device), Screen (GPU + finestra) i finalment SDL_Quit. Fer-ho així -- // NO via atexit -- evita crashes a vkDestroyInstance quan el runtime // desfà les llibreries abans que corren els destructors estàtics. current_scene_.reset(); Resource::Cache::get().clearAudio(); Resource::Helper::shutdownResourceSystem(); GamepadManager::get().shutdown(); Audio::destroy(); Screen::get().shutdown(); SDL_Quit(); } 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); // Subcarpeta per als presets de shaders. std::filesystem és tolerant si ja existeix. std::filesystem::create_directories(system_folder_ + "shaders/", 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, [](const SDL_Event& event) { return event.type == SDL_EVENT_QUIT; }); 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()) { // TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md). std::cout << "Director: scene transition\n"; current_scene_ = std::move(next); } } 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_) { EventBuffer::events.clear(); return SDL_APP_SUCCESS; } const std::uint64_t NOW_MS = SDL_GetTicks(); int delta_ms = static_cast(NOW_MS - last_ticks_ms_); delta_ms = std::max(delta_ms, 0); last_ticks_ms_ = NOW_MS; Audio::update(); Mouse::updateCursorVisibility(); ServiceMenu::get().update(delta_ms); current_scene_->update(delta_ms); current_scene_->render(); if (current_scene_->wantsQuit()) { EventBuffer::events.clear(); return SDL_APP_SUCCESS; } switchSceneIfRequested(); EventBuffer::events.clear(); return SDL_APP_CONTINUE; }