#include "core/system/director.hpp" #include #include #include #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/audio/sound_effects_config.hpp" #include "core/input/gamepad_bindings.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 "core/ui/ui_config.hpp" #include "game/debug_config.hpp" #ifdef _DEBUG #include "game/editor/editor_config.hpp" #endif #include "game/levels/level_manager.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 "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(); // Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS // de `Options::loadFromFile`, ja que aquesta crida internament a // `applyControlsToGamepadManager` (rebind d'assignacions persistides + // auto-assign de slots buits) i necessita que `entries_` ja estiga // poblat per a poder casar mandos↔jugadors. A partir d'ara els events // SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via `processGamepadEvents()` // i dispararan notificacions. GamepadManager::get().init(); 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'; } // Vinculacions de mando per dispositiu — fitxer aïllat de `config.yaml` // perquè és per (name, path) del mando, no per perfil/usuari. Replica // el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`). GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml"); // 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"); // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // data/ui.yaml cau al valor compilat a Defaults::UI. UiConfig::get().load("data/ui.yaml"); #ifdef _DEBUG // Tema visual de l'editor de tilemap (Debug-only). Claus absents // cauen als valors compilats a Defaults::Editor. EditorConfig::get().load("data/editor/editor.yaml"); #endif // 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 al fitxer data/ui.yaml via UiConfig. Resource::Cache::get().loadPalette("ui", "data/palettes/uzebox.pal"); const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif"; Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, UiConfig::get().hudFont().gif); Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); const std::string PLACEHOLDER_FONT_GIF_KEY = UiConfig::get().placeholderFont().name + ".gif"; Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, UiConfig::get().placeholderFont().gif); Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); // Font del rètol de transició de sala (la consumeix `GameScene` // tant per la línia "Piràmide N / Sala M" de l'INTRO com pel // "Fase completada" de l'OUTRO). Es preloda ací per consistència // amb la resta de fonts d'UI; sense això, la primera entrada a // GAME aniria a disc. const std::string ROOM_TEXT_FONT_GIF_KEY = UiConfig::get().roomTextFont().name + ".gif"; Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif); Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); // Pistes de música del cicle de sala (intro/playing/outro). Es preloden // ací perquè formen part del preload d'assets, com la font i la paleta; // la lògica de quina sona en cada estat viu a `GameScene`. Resource::Cache::get().loadMusic("room_intro.ogg", "data/music/room_intro.ogg"); Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg"); Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg"); // Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb // fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els // dispara via Audio::Group::INTERFACE. const auto& menu_select = UiConfig::get().menuSelectSound(); const auto& menu_accept = UiConfig::get().menuAcceptSound(); Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select); Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept); // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio // perquè els primers playSoundWith* (al començament del joc) ja // troben els presets disponibles. SoundEffectsConfig::get().load("data/sounds.yaml"); // 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()); // 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, UiConfig::get().hudFont().fnt); hud_text->setDefaultKerning(UiConfig::get().hudFont().kerning); const auto& ui_palette = Resource::Cache::get().getPalette("ui"); RenderInfo::get().init(Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()), 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, UiConfig::get().placeholderFont().fnt); placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().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 data/ui.yaml via UiConfig. auto load_menu_font = [&](const UiConfig::Font& font) { const std::string KEY = font.name + ".gif"; Resource::Cache::get().loadSurface(KEY, font.gif); Resource::Cache::get().getSurface(KEY)->setPalette(Resource::Cache::get().getPalette("ui")); auto text = std::make_shared(Resource::Cache::get().getSurface(KEY), font.fnt); text->setDefaultKerning(font.kerning); return text; }; auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont()); auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont()); // Mini-logo del peu de la TITLE. Es preloda ací per consistència // amb la resta d'assets d'UI; manté la seua paleta original (no la // sobreescrivim amb la "ui" perquè el GIF ja porta els colors finals // del logo). constexpr const char* MINI_LOGO_KEY = "logo_jailgames_mini.gif"; Resource::Cache::get().loadSurface(MINI_LOGO_KEY, "data/logo/logo_jailgames_mini.gif"); auto mini_logo_surface = Resource::Cache::get().getSurface(MINI_LOGO_KEY); // Fons sencer de la TITLE (320×240). Igual que el minilogo, conserva // la seua paleta nadiua perquè el GIF ja porta els colors finals. constexpr const char* TITLE_BG_KEY = "title_bg.gif"; Resource::Cache::get().loadSurface(TITLE_BG_KEY, "data/title/title_bg.gif"); auto title_bg_surface = Resource::Cache::get().getSurface(TITLE_BG_KEY); 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); const auto STARTING_PLAYERS = DebugConfig::resolveStartingPlayers(DEBUG_YAML); // `starting_level` (opcional) sobreescriu l'índex inicial del // LevelManager abans de crear cap GameScene. Si la clau no està o el // (pyramid, room) no apareix al levels.yaml, el LevelManager // continua a la primera entrada (P1-R1). if (const auto SL = DebugConfig::resolveStartingLevel(DEBUG_YAML); SL.has_value()) { Levels::LevelManager::get().setStartingPoint(SL->pyramid, SL->room); } #else const auto START = DebugConfig::StartScene::LOGO; const auto STARTING_PLAYERS = DebugConfig::StartingPlayers::BOTH; #endif std::cout << "Director: start_scene=" << DebugConfig::toString(START) << " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n'; // El branch Release inicialitza STARTING_PLAYERS amb una constant // literal i clang considera la captura redundant; en Debug el valor és // runtime i la captura sí és imprescindible. La lambda viu dins de // IntroScene, més enllà del scope d'aquest ctor — capturar per // referència seria UB. // NOLINTNEXTLINE(clang-diagnostic-unused-lambda-capture) auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr { auto cfg = GameScene::defaultConfig(); cfg.starting_players = STARTING_PLAYERS; return std::make_unique(std::move(cfg)); }; auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr { return std::make_unique(placeholder_text, make_game); }; auto make_title = [menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro]() -> std::unique_ptr { return std::make_unique(menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, 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(); } } namespace { // Resol el botó SERVICE configurat per al mando origen d'aquest event. // Retorna -1 si no en troba cap. La cerca passa per `GamepadManager` // per obtenir el (name, path) i d'ahí a `GamepadBindings`. auto serviceButtonForGamepadEvent(SDL_JoystickID jid) -> int { for (const auto& info : GamepadManager::get().getAll()) { if (info.pad == nullptr) { continue; } if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; } return GamepadBindings::get().getButton(info.name, info.path, GamepadBindings::Action::SERVICE); } return -1; } } // namespace 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ó SERVICE (per defecte BACK; remapejable per // `GamepadBindings`) com a toggle del menú quan està tancat (el // paral·lel de F9 per al teclat). Quan el menú està obert, el botó // SERVICE 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 && !ServiceMenu::get().isOpen()) { const int SERVICE_BTN = serviceButtonForGamepadEvent(it->gbutton.which); if (SERVICE_BTN >= 0 && static_cast(it->gbutton.button) == SERVICE_BTN) { 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; }