#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/effects/shadow.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/screenshot.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" #include "game/gameplay_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" #include "utils/log.hpp" Director::Director() { if (!SDL_Init(0)) { Log::critical("Director", "SDL_Init failed: {}", 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)) { Log::critical("Director", "sistema de recursos no disponible; avortem"); std::exit(1); } // Prepara la carpeta de sistema (per plataforma: %APPDATA% a Windows, // ~/Library/Application Support a macOS, ~/.config a Linux) 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()) { Log::error("Director", "no s'ha pogut carregar ni desar config.yaml a {}", system_folder_); } // 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"); // Tweaks de jugabilitat (látigo travessa sarcs, friendly fire, col·lisió // entre jugadors). Fitxer escrivible des del menú de servei (F9). Si // no existeix, GameplayConfig::load el crea amb els defaults. GameplayConfig::get().load(system_folder_ + "gameplay.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()) { Log::error("Director", "no s'ha pogut carregar ni desar postfx.yaml."); } // Presets del shader CRT-Pi (shaders/crtpi.yaml). Options::setCrtPiFile(system_folder_ + Defaults::Video::CRTPI_FILE); if (!Options::loadCrtPiFromFile()) { Log::error("Director", "no s'ha pogut carregar ni desar crtpi.yaml."); } // 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(Defaults::Paths::INPUT_YAML); // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // data/config/ui.yaml cau al valor compilat a Defaults::UI. UiConfig::get().load(Defaults::Paths::UI_YAML); // Configuració específica de les escenes TITLE i LOGO. Es carreguen // additivament sobre el mateix UiConfig: cada fitxer només conté la // seua subclau (`title:` o `logo:`), i les claus absents a la base // (ui.yaml) o als yamls d'escena cauen al valor compilat a // Defaults::UI::Title / Defaults::UI::Logo. UiConfig::get().load(Defaults::Paths::TITLE_YAML); UiConfig::get().load(Defaults::Paths::LOGO_YAML); #ifdef _DEBUG // Tema visual de l'editor de tilemap (Debug-only). Claus absents // cauen als valors compilats a Defaults::Editor. EditorConfig::get().load(Defaults::Paths::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/config/ui.yaml via UiConfig. Resource::Cache::get().loadPalette("ui", Defaults::Paths::UI_PALETTE); 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")); // Sprite compartit de la sombra (tira horitzontal de fotogrames de // 16x16, de petit a gran). El consumeix `Effects::Shadow::render`, // que el reaprofiten Player i qualsevol entitat futura compatible. Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif"); // 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", Defaults::Paths::MUSIC_ROOM_INTRO); Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING); Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO); // Pista de game over: pre-load igual que la resta perquè el playMusic // del moment en què es dispara la sub-FSM no facti disc. Resource::Cache::get().loadMusic("game_over.ogg", Defaults::Paths::MUSIC_GAME_OVER); // Música de la TITLE: arrenca al constructor de l'escena i es fa // fade-out quan l'escena entra a STARTING. Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE); // Sons del menú de servei. Els noms venen de `data/config/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, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + menu_select); Resource::Cache::get().loadSound(menu_accept, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + menu_accept); // So del START a la TITLE: el dispara TitleScene quan un jugador // prem START (vore enterStarting i el reset del segon jugador). const auto& player_start = UiConfig::get().playerStartSound(); Resource::Cache::get().loadSound(player_start, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + player_start); // Soroll de LogoScene (scanline reveal). El dispara LogoScene al // ctor; amb fallback al nom de Defaults si la clau YAML no hi és. const auto& logo_glitch = UiConfig::get().logoGlitchSound(); Resource::Cache::get().loadSound(logo_glitch, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + logo_glitch); // 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(Defaults::Paths::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); placeholder_text_ = placeholder_text; // Font del copyright de la TITLE (Tiny5-Regular size 8). Pixel-art // estricte; va amb la paleta UI per compartir índex de color amb la // resta de textos placeholder. const std::string COPYRIGHT_FONT_GIF_KEY = UiConfig::get().copyrightFont().name + ".gif"; Resource::Cache::get().loadSurface(COPYRIGHT_FONT_GIF_KEY, UiConfig::get().copyrightFont().gif); Resource::Cache::get().getSurface(COPYRIGHT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); auto copyright_text = std::make_shared(Resource::Cache::get().getSurface(COPYRIGHT_FONT_GIF_KEY), UiConfig::get().copyrightFont().fnt); copyright_text->setDefaultKerning(UiConfig::get().copyrightFont().kerning); copyright_text_ = copyright_text; // 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/config/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, Defaults::Paths::LOGO_MINI_GIF); auto mini_logo_surface = Resource::Cache::get().getSurface(MINI_LOGO_KEY); mini_logo_surface_ = mini_logo_surface; // Logo sencer per a l'escena LOGO (centrat damunt fons negre). També // conserva la seua paleta nadiua: els colors finals del logo viuen // dins del propi GIF i no han de passar per la paleta UI. constexpr const char* LOGO_FULL_KEY = "logo_jailgames.gif"; Resource::Cache::get().loadSurface(LOGO_FULL_KEY, Defaults::Paths::LOGO_FULL_GIF); logo_full_surface_ = Resource::Cache::get().getSurface(LOGO_FULL_KEY); // Logotip del joc per a la TITLE (substitueix el text "Aventures en // Egipte ARCADE"). Igual que la resta de logos, paleta nadiua — // l'art ja porta els colors finals i no passa per la UI. constexpr const char* GAME_LOGO_KEY = "game_logo.gif"; Resource::Cache::get().loadSurface(GAME_LOGO_KEY, Defaults::Paths::TITLE_GAME_LOGO_GIF); game_logo_surface_ = Resource::Cache::get().getSurface(GAME_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, Defaults::Paths::TITLE_BG_GIF); auto title_bg_surface = Resource::Cache::get().getSurface(TITLE_BG_KEY); title_bg_surface_ = title_bg_surface; 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 Log::info("Director", "start_scene={} starting_players={}", DebugConfig::toString(START), DebugConfig::toString(STARTING_PLAYERS)); switch (START) { case DebugConfig::StartScene::LOGO: current_scene_ = buildScene(SceneId::LOGO, LogoParams{}); break; case DebugConfig::StartScene::TITLE: current_scene_ = buildScene(SceneId::TITLE, TitleParams{}); break; case DebugConfig::StartScene::INTRO: current_scene_ = buildScene(SceneId::INTRO, IntroParams{.starting_players = STARTING_PLAYERS}); break; case DebugConfig::StartScene::GAME: current_scene_ = buildScene(SceneId::GAME, GameParams{.starting_players = STARTING_PLAYERS}); 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() { // Carpeta de configuració per plataforma: // - Windows: %APPDATA%\jailgames\aee_arcade\ // - macOS: ~/Library/Application Support/jailgames/aee_arcade/ // - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte // ~/.config/jailgames/aee_arcade/) // No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME // (~/.local/share), que és per a dades, no per a configuració. std::string base; #ifdef _WIN32 const char* appdata = std::getenv("APPDATA"); base = (appdata != nullptr && *appdata != '\0') ? std::string(appdata) : "."; #elif defined(__APPLE__) const char* home = std::getenv("HOME"); base = (home != nullptr) ? std::string(home) + "/Library/Application Support" : "."; #else const char* xdg = std::getenv("XDG_CONFIG_HOME"); if (xdg != nullptr && *xdg != '\0') { base = xdg; } else { const char* home = std::getenv("HOME"); base = (home != nullptr) ? std::string(home) + "/.config" : "."; } #endif system_folder_ = base + "/jailgames/aee_arcade/"; 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) { Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message()); } // Les captures de pantalla viuen sota la mateixa carpeta per // plataforma. Sense aquesta crida, `Screenshot::save` cauria al // CWD del procés (raïz del projecte si llances `build/aee_arcade` // a mà, lloc imprevisible en una release de macOS/Windows). Screenshot::setBaseDir(system_folder_); } 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; } // Prioritat al menú: si un item del ServiceMenu ha encuat una // transició (típicament "Reiniciar" → LOGO amb reset), s'aplica // primer. Substitueix l'`ExitRequest` que pugui haver publicat // l'escena en aquest mateix frame — l'usuari ha cancel·lat l'estat // de joc explícitament i no ens interessa mantenir-ne la sortida. if (auto menu_req = ServiceMenu::get().takePendingSceneRequest(); menu_req.has_value()) { Log::info("Director", "scene transition (menu) → {}", toString(menu_req->next)); current_scene_ = buildScene(menu_req->next, menu_req->params); return; } // L'escena ha publicat un `ExitRequest` dins de `exit_request_`. // Director el desempaqueta i construeix la nova escena via // `buildScene`. Si la `std::any_cast` falla (tipus inesperat), el // log avisa i no canviem d'escena. auto request = current_scene_->takeExitRequest(); if (!request.has_value()) { return; } try { const auto& req = std::any_cast(request); Log::info("Director", "scene transition → {}", toString(req.next)); current_scene_ = buildScene(req.next, req.params); } catch (const std::bad_any_cast&) { Log::error("Director", "exit_request_ amb tipus inesperat — ignorant"); } } auto Director::buildScene(SceneId id, const SceneParams& params) -> std::unique_ptr { // Durant la migració, les escenes que encara fan servir factories // legacy reben lambdes primes que tornen a delegar a `buildScene`. // Així el motor d'escena ja és Director-driven encara que els // ctors antics no l'utilitzen directament. Quan totes estiguen // migrades a `ExitRequest`, aquestes lambdes desapareixeran. switch (id) { case SceneId::LOGO: { const auto& p = std::get(params); return std::make_unique(logo_full_surface_, p.reset); } case SceneId::TITLE: return std::make_unique(placeholder_text_, copyright_text_, title_bg_surface_, game_logo_surface_, mini_logo_surface_); case SceneId::INTRO: { const auto& p = std::get(params); return std::make_unique(placeholder_text_, p.starting_players); } case SceneId::GAME: { const auto& p = std::get(params); auto cfg = GameScene::defaultConfig(); cfg.starting_players = p.starting_players; return std::make_unique(std::move(cfg)); } } return nullptr; } 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; }