From 9b71ee447a9a33e5beb21e98861b8a2361e31b99 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Fri, 23 Oct 2026 23:49:54 +0600 Subject: [PATCH] Working on loading --- resources/config/campaigns.json | 6 +- resources/w/ui/screen_loading.json | 29 +++++ resources/w/ui_en/screen_loading.json | 29 +++++ src/Campaign.cpp | 1 + src/Campaign.h | 4 + src/Game.cpp | 170 +++++++++++++++++++------- src/Game.h | 17 ++- src/MenuManager.cpp | 22 +++- src/MenuManager.h | 14 ++- src/render/TextureManager.cpp | 22 ++++ src/render/TextureManager.h | 9 ++ 11 files changed, 268 insertions(+), 55 deletions(-) create mode 100644 resources/w/ui/screen_loading.json create mode 100644 resources/w/ui_en/screen_loading.json diff --git a/resources/config/campaigns.json b/resources/config/campaigns.json index 7669b1a..d312f1f 100644 --- a/resources/config/campaigns.json +++ b/resources/config/campaigns.json @@ -12,7 +12,8 @@ "startMusic": "music/obshaga.ogg", "startState": "resources/config/start_state.json", "quests": "resources/config/quests.json", - "tutorial": true + "tutorial": true, + "loadingScreen": "resources/w/ui/screen_loading.json" }, { "id": "battle_zone", @@ -24,7 +25,8 @@ "startMusic": "music/bishkek univer day.ogg", "startState": "resources/config/start_state_battle_zone.json", "quests": "resources/config/quests_battle_zone.json", - "tutorial": false + "tutorial": false, + "loadingScreen": "resources/w/ui/screen_loading.json" } ] } diff --git a/resources/w/ui/screen_loading.json b/resources/w/ui/screen_loading.json new file mode 100644 index 0000000..3b2cb9d --- /dev/null +++ b/resources/w/ui/screen_loading.json @@ -0,0 +1,29 @@ +{ + "root": { + "type": "FrameLayout", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "StaticImage", + "name": "loadingBkg", + "width": 2992, + "height": 1632, + "horizontal_gravity": "center", + "vertical_gravity": "center", + "texture": "resources/w/ui/img/main/MenuBkg001_v001.png" + }, + { + "type": "TextView", + "name": "loadingText", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "width": 600, + "height": 80, + "text": "Загрузка...", + "fontSize": 48, + "textCentered": true + } + ] + } +} diff --git a/resources/w/ui_en/screen_loading.json b/resources/w/ui_en/screen_loading.json new file mode 100644 index 0000000..b40f443 --- /dev/null +++ b/resources/w/ui_en/screen_loading.json @@ -0,0 +1,29 @@ +{ + "root": { + "type": "FrameLayout", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "StaticImage", + "name": "loadingBkg", + "width": 2992, + "height": 1632, + "horizontal_gravity": "center", + "vertical_gravity": "center", + "texture": "resources/w/ui/img/main/MenuBkg001_v001.png" + }, + { + "type": "TextView", + "name": "loadingText", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "width": 600, + "height": 80, + "text": "Loading...", + "fontSize": 48, + "textCentered": true + } + ] + } +} diff --git a/src/Campaign.cpp b/src/Campaign.cpp index fb4f77a..1734a0a 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -96,6 +96,7 @@ namespace FRG { c.startStatePath = entry.value("startState", ""); c.questsPath = entry.value("quests", ""); c.tutorial = entry.value("tutorial", false); + c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen)); campaigns_.push_back(std::move(c)); } diff --git a/src/Campaign.h b/src/Campaign.h index 67ce177..7e4a188 100644 --- a/src/Campaign.h +++ b/src/Campaign.h @@ -50,6 +50,10 @@ namespace FRG { std::string startStatePath; std::string questsPath; bool tutorial = false; + // UI layout shown while the campaign loads (preloaded with the rest of the UI). + std::string loadingScreenPath = kDefaultLoadingScreen; + + static constexpr const char* kDefaultLoadingScreen = "resources/w/ui/screen_loading.json"; std::string title(Language lang) const; }; diff --git a/src/Game.cpp b/src/Game.cpp index b791fac..fff1ff4 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -327,18 +327,10 @@ namespace FRG Character::nameTranslations.loadFromFile("resources/config/translate_names.json"); }); - // Every campaign's locations are built up front (one loading step per location), - // so starting or loading any campaign later only switches the active set. + // Campaigns are only listed here; their locations are built on demand when the + // player starts or loads one (see loadCampaignThen). MenuManager::setup() needs + // the list to preload each campaign's loading screen. CampaignRegistry::instance().loadFromJson("resources/config/campaigns.json", CONST_ZIP_FILE); - loadSteps.push([this]() { - gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load - }); - for (const CampaignConfig& campaign : CampaignRegistry::instance().all()) { - queueCampaignBuildSteps(campaign); - } - loadSteps.push([this]() { - activateCampaign(CampaignRegistry::kDefaultCampaignId); - }); loadSteps.push([this]() { // Wire inventory callbacks: tutorial tracking + toast notifications. @@ -393,17 +385,28 @@ namespace FRG } menuManager.startGameFunc = [this](const std::string& campaignId) { - mainThreadHandler.EnqueueMainThreadTask([this, campaignId]() { - activateCampaign(campaignId); - performResetToInitialState(); - applyTutorialSetting(); - const CampaignConfig& campaign = activeCampaign(); - gameState.currentLocationName = campaign.startLocation; - menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state - if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback(); - audioPlayer->crossFadeMusicAsync(campaign.startMusic); - menuManager.lastMusicTrack = campaign.startMusic; - }); + auto start = [this, campaignId]() { + loadCampaignThen(campaignId, [this]() { + performResetToInitialState(); + applyTutorialSetting(); + const CampaignConfig& campaign = activeCampaign(); + gameState.currentLocationName = campaign.startLocation; + menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state + if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback(); + audioPlayer->crossFadeMusicAsync(campaign.startMusic); + menuManager.lastMusicTrack = campaign.startMusic; + }); + }; + if (campaignLocations.count(campaignId)) { + // Already loaded: the reset runs right away, so keep it off the menu + // button's call stack. + mainThreadHandler.EnqueueMainThreadTask(start); + } else { + // Not loaded: this only shows the loading screen and queues the build + // steps, so start it now -- deferring it would draw one more frame of + // the main menu before the loading screen appears. + start(); + } }; menuManager.onSaveGame = [this](int slot) { saveGame(slot); }; @@ -429,7 +432,11 @@ namespace FRG // the duration, exactly like the initial resource load. menuManager.runAsLoadingSequence = [this](std::vector> steps) { for (auto& step : steps) loadSteps.push(std::move(step)); - loadSteps.push([this]() { loadingCompleted = true; }); + loadSteps.push([this]() { + loadingCompleted = true; + // Hidden together with loadingCompleted, so no frame falls back to drawLoading(). + menuManager.hideLoadingScreen(); + }); loadStepsTotal = loadSteps.size(); loadStepsDone = 0; showLoadingProgressBar = false; @@ -462,6 +469,10 @@ namespace FRG }); loadSteps.push([this]() { + // Everything loaded so far (UI, portraits, shared assets) stays loaded for the + // whole session; textures loaded later belong to a campaign and are released + // when it is unloaded. + renderer.textureManager.MarkAllResident(); loadingCompleted = true; }); @@ -478,15 +489,8 @@ namespace FRG } void Game::activateCampaign(const std::string& campaignId) { - auto it = campaignLocations.find(campaignId); - if (it == campaignLocations.end()) { - std::cerr << "[campaign] Unknown campaign '" << campaignId << "', using '" - << CampaignRegistry::kDefaultCampaignId << "'" << std::endl; - it = campaignLocations.find(CampaignRegistry::kDefaultCampaignId); - if (it == campaignLocations.end()) return; - } - gameState.campaignId = it->first; - gameState.locations = it->second; + gameState.campaignId = campaignId; + gameState.locations = campaignLocations[campaignId]; gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE); } @@ -500,18 +504,55 @@ namespace FRG return *campaign; } - void Game::queueCampaignBuildSteps(const CampaignConfig& campaign) { - const std::string campaignId = campaign.id; - for (const std::string& configPath : campaign.locationConfigPaths) { - loadSteps.push([this, campaignId, configPath]() { + void Game::loadCampaignThen(const std::string& requestedId, std::function then) { + const CampaignConfig* campaign = CampaignRegistry::instance().find(requestedId); + if (!campaign) { + std::cerr << "[campaign] Unknown campaign '" << requestedId << "', using '" + << CampaignRegistry::kDefaultCampaignId << "'" << std::endl; + campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId); + if (!campaign) return; + } + const std::string campaignId = campaign->id; + + if (campaignLocations.count(campaignId)) { + activateCampaign(campaignId); + then(); + return; + } + + // Build it behind the campaign's loading screen, one location per loading step. + // The screen is hidden when the sequence completes (see runAsLoadingSequence). + menuManager.showLoadingScreen(campaign->loadingScreenPath); + std::vector> steps; + steps.push_back([this, campaignId]() { + unloadCampaigns(); // only one campaign is kept in memory + campaignLocations[campaignId]; + gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load + }); + for (const std::string& configPath : campaign->locationConfigPaths) { + steps.push_back([this, campaignId, configPath]() { LocationConfig config; if (LocationConfig::fromFile(configPath, CONST_ZIP_FILE, config)) campaignLocations[campaignId][config.id] = createLocation(config); }); } - loadSteps.push([this, campaignId]() { - wireCampaignLocations(campaignLocations[campaignId]); + steps.push_back([this, campaignId, then]() { + LocationMap& locations = campaignLocations[campaignId]; + wireCampaignLocations(locations); + if (shadowMap) { + for (auto& [name, loc] : locations) loc->shadowMap = shadowMap; + } + activateCampaign(campaignId); + then(); }); + menuManager.runAsLoadingSequence(std::move(steps)); + } + + void Game::unloadCampaigns() { + gameState.locations.clear(); + campaignLocations.clear(); + // Drop the cache entries of the textures the campaign loaded; UI images stay. + renderer.textureManager.UnloadNonResident("resources/w/ui"); } void Game::performFullReset() { @@ -783,8 +824,11 @@ namespace FRG } // Wire HP-change callbacks so all player instances update the health bar HUD. - for (auto& [name, loc] : locations) { - if (loc->player) { + for (auto& [name, locPtr] : locations) { + if (locPtr->player) { + // Raw pointer: the location owns this player, so a shared_ptr here would be + // a reference cycle that keeps unloaded locations alive. + Location* loc = locPtr.get(); loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) { menuManager.updateHealthBar(hp, maxHp); @@ -875,10 +919,33 @@ namespace FRG + // Campaign loading screen: a UI layout drawn with the same state as the HUD. + void Game::drawLoadingScreenUi() + { + glClear(GL_DEPTH_BUFFER_BIT); + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + glEnable(GL_BLEND); + menuManager.loadingUiManager.draw(renderer); + glDisable(GL_BLEND); + renderer.shaderManager.PopShader(); + + glDepthMask(GL_TRUE); + glEnable(GL_DEPTH_TEST); + + CheckGlError(__FILE__, __LINE__); + } + void Game::drawScene() { glViewport(0, 0, Environment::width, Environment::height); if (!loadingCompleted) { - drawLoading(); + if (menuManager.isLoadingScreenShown()) + drawLoadingScreenUi(); + else + drawLoading(); } /*else if (Environment::width < Environment::height) { drawMobilePortraitOverlay(); @@ -1070,6 +1137,7 @@ namespace FRG updateDarklandsFlash(delta); menuManager.uiManager.update(static_cast(delta)); menuManager.topUiManager.update(static_cast(delta)); + menuManager.loadingUiManager.update(static_cast(delta)); if (!menuManager.isMainMenuOpen()) menuManager.update(static_cast(delta)); @@ -1115,6 +1183,7 @@ namespace FRG const bool isPortrait = Environment::width < Environment::height; menuManager.uiManager.updateAllLayouts(); menuManager.topUiManager.updateAllLayouts(); + menuManager.loadingUiManager.updateAllLayouts(); if (wasPortrait != isPortrait) { menuManager.onOrientationChanged(); @@ -1748,16 +1817,25 @@ namespace FRG std::cerr << "[save] Save file not found or empty: " << path << std::endl; return; } + nlohmann::json root; try { - nlohmann::json root = nlohmann::json::parse(content); - // Every campaign is built at startup, so loading only switches to the save's - // campaign (its locations and quest definitions) before applying the state. - activateCampaign(root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId))); - gameState.load(root); + root = nlohmann::json::parse(content); } catch (const std::exception& e) { std::cerr << "[save] Failed to parse save file " << path << ": " << e.what() << std::endl; + return; } + // The save's campaign may not be loaded yet; the state is applied once it is. + const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId)); + loadCampaignThen(campaignId, [this, root]() { + gameState.load(root); + playMusicForLoadedGame(); + menuManager.enterGameplay(true); + }); + } + + void Game::playMusicForLoadedGame() + { if (gameState.isDarklands) { audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg"); diff --git a/src/Game.h b/src/Game.h index 0102efd..64685a2 100644 --- a/src/Game.h +++ b/src/Game.h @@ -126,18 +126,24 @@ namespace FRG { void saveGame(int slot); void loadGame(int slot); + void playMusicForLoadedGame(); SaveSlotInfo readSlotInfo(int slot) const; - // Every campaign's locations, all built at startup (campaign id -> locations). - // gameState.locations is a copy of the active campaign's entry. + // Locations of the loaded campaign (campaign id -> locations); at most one entry, + // empty until the player starts or loads a game. gameState.locations is a copy + // of the active campaign's entry. std::unordered_map campaignLocations; // Builds one location from its config and wires the callbacks every location gets. std::shared_ptr createLocation(const LocationConfig& config); // Wires the callbacks that span a campaign's locations (teleports, dialogue, HUD...). void wireCampaignLocations(LocationMap& locations); - // Queues one loading step per location of `campaign`, plus its wiring step. - void queueCampaignBuildSteps(const CampaignConfig& campaign); - // Makes `campaignId` the played campaign: its locations and its quest definitions. + // Runs `then` with the campaign active. If its locations aren't loaded, they are + // built first behind the campaign's loading screen (one loading step per + // location) and any other loaded campaign is released. + void loadCampaignThen(const std::string& campaignId, std::function then); + // Releases every loaded campaign's locations and the textures only they used. + void unloadCampaigns(); + // Makes a loaded campaign the played one: its locations and its quest definitions. void activateCampaign(const std::string& campaignId); const CampaignConfig& activeCampaign() const; @@ -168,6 +174,7 @@ namespace FRG { void drawUI(); void drawLoading(); + void drawLoadingScreenUi(); void onPointerDown(int64_t fingerId, int eventX, int eventY, int mx, int my); void onPointerUp(int64_t fingerId, int eventX, int eventY, int mx, int my); diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index 45cd0fb..c67112a 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -325,6 +325,11 @@ namespace FRG { languageScreenRoot = loadLocalizedUi("resources/w/ui/screen_language.json", zipFile); loadSavedGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_load.json", zipFile); campaignScreenRoot = loadLocalizedUi("resources/w/ui/screen_campaign.json", zipFile); + loadingScreenRoots_.clear(); + for (const CampaignConfig& campaign : CampaignRegistry::instance().all()) { + if (!loadingScreenRoots_.count(campaign.loadingScreenPath)) + loadingScreenRoots_[campaign.loadingScreenPath] = loadLocalizedUi(campaign.loadingScreenPath, zipFile); + } saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile); } @@ -385,6 +390,21 @@ namespace FRG { if (startGameFunc) startGameFunc(campaignId); } + void MenuManager::showLoadingScreen(const std::string& layoutPath) { + auto it = loadingScreenRoots_.find(layoutPath); + if (it == loadingScreenRoots_.end() || !it->second) { + std::cerr << "[menu] Loading screen not preloaded: " << layoutPath << std::endl; + return; // Game falls back to the startup loading image + } + loadingUiManager.replaceRoot(it->second); + loadingScreenShown_ = true; + } + + void MenuManager::hideLoadingScreen() { + loadingUiManager.replaceRoot(nullptr); + loadingScreenShown_ = false; + } + void MenuManager::enterGameplay(bool isFromLoad) { if (isFromLoad) { uiManager.clearMenuStack(); @@ -1080,8 +1100,8 @@ namespace FRG { } uiManager.setTextButtonCallback(kSlotButtons[i], [this, slot](const std::string&) { audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); + // Game enters gameplay itself once the save (and its campaign) is loaded. if (onLoadGame) onLoadGame(slot); - enterGameplay(true); }); } } diff --git a/src/MenuManager.h b/src/MenuManager.h index 8515f67..1afa482 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -33,6 +33,8 @@ namespace FRG { UiManager uiManager; UiManager topUiManager; UiManager chatUiManager; + // Draws the campaign loading screen while Game builds a campaign. + UiManager loadingUiManager; std::unordered_map> localizedLocationName; @@ -66,6 +68,14 @@ namespace FRG { // Shows the HUD for the current location with its callbacks wired and visibility // applied. Game calls it once a new game's state has been reset. void showGameplayHud(); + // Called by Game once a save has been applied. + void enterGameplay(bool isFromLoad = false); + + // Campaign loading screen (layout from CampaignConfig::loadingScreenPath, preloaded + // with the UI). Game shows it before building a campaign and hides it when done. + void showLoadingScreen(const std::string& layoutPath); + void hideLoadingScreen(); + bool isLoadingScreenShown() const { return loadingScreenShown_; } void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = ""); @@ -166,7 +176,6 @@ namespace FRG { AudioPlayerAsync& audioPlayer_; void startNewGame(const std::string& campaignId); - void enterGameplay(bool isFromLoad = false); // HUD layout for the current location and darklands mode. std::shared_ptr gameplayHudRoot() const; // Single place that decides which HUD buttons and tutorial hints are visible: @@ -299,6 +308,9 @@ namespace FRG { std::shared_ptr languageScreenRoot; std::shared_ptr loadSavedGameScreenRoot; std::shared_ptr campaignScreenRoot; + // Loading screen layouts by path; campaigns may share one. + std::unordered_map> loadingScreenRoots_; + bool loadingScreenShown_ = false; std::shared_ptr saveGameScreenRoot; diff --git a/src/render/TextureManager.cpp b/src/render/TextureManager.cpp index f7f30cd..75bbd6d 100644 --- a/src/render/TextureManager.cpp +++ b/src/render/TextureManager.cpp @@ -553,4 +553,26 @@ namespace FRG textureMap.clear(); } + void TextureManager::MarkAllResident() + { + for (const auto& [key, tex] : textureMap) residentKeys.insert(key); + } + + void TextureManager::UnloadNonResident(const std::string& residentPrefix) + { + size_t unloaded = 0; + for (auto it = textureMap.begin(); it != textureMap.end(); ) { + const bool keep = residentKeys.count(it->first) + || (!residentPrefix.empty() && it->first.compare(0, residentPrefix.size(), residentPrefix) == 0); + if (keep) { + ++it; + } + else { + it = textureMap.erase(it); + ++unloaded; + } + } + logger() << "Unloaded " << unloaded << " non-resident textures" << std::endl; + } + } \ No newline at end of file diff --git a/src/render/TextureManager.h b/src/render/TextureManager.h index 7f641ff..5b1ff23 100644 --- a/src/render/TextureManager.h +++ b/src/render/TextureManager.h @@ -4,6 +4,7 @@ #include "utils/Utils.h" #include #include +#include #if defined EMSCRIPTEN || defined __ANDROID__ #define PNG_ENABLED @@ -79,8 +80,16 @@ namespace FRG void UnloadByPrefix(const std::string& prefix); void UnloadAll(); + // Marks every texture cached so far as resident, so UnloadNonResident() keeps it. + void MarkAllResident(); + // Drops cached textures that aren't resident and whose key doesn't start with + // residentPrefix (e.g. UI images, which are reloaded under the same prefix). A + // texture is freed once nothing else holds it. + void UnloadNonResident(const std::string& residentPrefix = ""); + private: std::unordered_map> textureMap; + std::unordered_set residentKeys; static std::string MakeKey(const std::string& fileName, const std::string& zipFile); };