#include "Game.h" #include "game_server/NetworkProtocol.h" #include "AnimatedModel.h" #include "Localization.h" #include "utils/Utils.h" #include #include "items/ItemRegistry.h" #include "Campaign.h" #include "render/OpenGlExtensions.h" #include #include "render/TextureManager.h" #include "TextModel.h" #include #include #include #include #include #ifdef __ANDROID__ #include #endif #ifdef __EMSCRIPTEN__ #include #endif #include "GameConstants.h" #ifdef __ANDROID__ extern bool isAppActive; #endif namespace FRG { static const float zoomMin = 6.0f; static const float zoomMax = 20.0f; #ifdef __EMSCRIPTEN__ const char* CONST_ZIP_FILE = "resources.zip"; const char* CONST_MUSIC_ZIP_FILE = "music.zip"; #else const char* CONST_ZIP_FILE = ""; const char* CONST_MUSIC_ZIP_FILE = ""; #endif float x = 0; float y = 0; float z = 0; #ifdef __EMSCRIPTEN__ Game* Game::s_instance = nullptr; // Коллбэк успешной загрузки resources.zip void Game::onResourcesZipLoaded(unsigned /*handle*/, void* /*userData*/, const char* /*filename*/) { logger() << "Resources.zip downloaded successfully to VFS" << std::endl; // Синхронизируем MemFS -> IndexedDB, чтобы файл сохранился между сессиями браузера FRG::emscriptenSyncFs(); if (s_instance) { s_instance->resourcesDownloadProgress = 1.0f; s_instance->setupPart2(); } } void Game::onResourcesZipError(unsigned /*handle*/, void* /*userData*/, int httpStatus) { logger() << "Failed to download resources.zip, HTTP status: " << httpStatus << std::endl; // Можно обработать ошибку или предложить пользователю перезагрузить страницу } void Game::onResourcesZipProgress(unsigned /*handle*/, void* /*userData*/, int percentComplete) { if (s_instance) s_instance->resourcesDownloadProgress = percentComplete / 100.0f; } #endif Location* Game::currentLocation() const { const auto state = stateSnapshot(); if (!state || state->currentLocationId.empty()) return nullptr; return gameServerHost.runtimeLocation(state->currentLocationId); } Game::Game() : audioPlayer(std::make_unique()) , internalGameServer() , localGameServerHost(internalGameServer) , gameServer(internalGameServer) , gameServerHost(localGameServerHost) , menuManager(renderer, gameServer, *audioPlayer) , lastTickCount(0) { } Game::~Game() { } void Game::setup() { #ifndef __ANDROID__ Environment::width = Environment::CONST_DEFAULT_WIDTH; Environment::height = Environment::CONST_DEFAULT_HEIGHT; #endif Environment::computeProjectionDimensions(); FRG::BindOpenGlFunctions(); FRG::CheckGlError(__FILE__, __LINE__); renderer.InitOpenGL(); //logger() << "Android test step 1 " << std::endl; #if defined(__EMSCRIPTEN__) || defined(__ANDROID__) // These shaders and loading.png are preloaded separately (not from zip), // so they are available immediately without waiting for resources.zip. //logger() << "Android test step 1 " << std::endl; renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_web.fragment", ""); //logger() << "Android test step 2 " << std::endl; renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_web.fragment", ""); //logger() << "Android test step 3 " << std::endl; #elif defined(__linux__) renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); #else renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); #endif loadingTexture = renderer.textureManager.LoadFromPng("resources/loading.png", ""); //logger() << "Android test step 4 " << std::endl; loadingProgressBarFrameTexture = renderer.textureManager.LoadFromPng( "resources/loadingProgressBarFrame.png", "", true); loadingProgressBarTexture = renderer.textureManager.LoadFromPng( "resources/loadingProgressBar.png", "", true); //logger() << "Android test step 5 " << std::endl; float minDimension; float width = Environment::projectionWidth; float height = Environment::projectionHeight; if (width >= height) { minDimension = height; } else { minDimension = width; } loadingMesh.data = CreateRect2D({ 0.0f, 0.0f }, { minDimension * 0.5f, minDimension * 0.5f }, 3); loadingMesh.RefreshVBO(); #ifdef __EMSCRIPTEN__ s_instance = this; std::string resPath = FRG::getPersistentResourcePath("resources.zip"); std::string musicPath = FRG::getPersistentResourcePath("music.zip"); // 1. Проверяем ресурсы (resources.zip) if (FRG::isZipValid(resPath)) { logger() << "[Load] Valid resources.zip found in IndexedDB cache: " << resPath << std::endl; resourcesDownloadProgress = 1.0f; // Архив уже есть и цел — переходим сразу к разбору/загрузке setupPart2(); } else { logger() << "[Load] Resources.zip missing or corrupt. Downloading to: " << resPath << std::endl; // Скачиваем сразу в постоянный путь /offline/resources.zip emscripten_async_wget2( "resources.zip?v=2", // Cache-bust stale browser responses after resource updates resPath.c_str(), // Назначение в VFS Emscripten "GET", nullptr, nullptr, onResourcesZipLoaded, onResourcesZipError, onResourcesZipProgress ); } /* // 2. Проверяем музыку (music.zip) if (FRG::isZipValid(musicPath)) { logger() << "[Load] Valid music.zip found in IndexedDB cache." << std::endl; } else { logger() << "[Load] Music.zip missing or corrupt. Downloading to: " << musicPath << std::endl; emscripten_async_wget2( "music.zip", musicPath.c_str(), "GET", nullptr, nullptr, onMusicZipLoaded, onMusicZipError, onMusicZipProgress ); }*/ #else // Builds the loadSteps queue (cheap, no GL work); the actual loading // happens one step per frame in Game::update() so the window keeps // pumping events / repainting instead of freezing for the whole load. setupPart2(); #endif } void Game::setupPart2() { // Builds an ordered queue of small closures instead of doing the work here. // Game::update() drains one closure per frame, so the event loop / browser // paint gets a turn between steps instead of the window freezing for the // whole duration of loading. Building the queue itself is cheap (no GL calls). // Must happen before the location step below: some location scripts // (e.g. resources/start_dorm.lua) start a dialogue as soon as they're // loaded, so the language needs to be known before that first load. loadSteps.push([this]() { FRG::loadLanguageSetting(); }); loadSteps.push([this]() { #if defined(__EMSCRIPTEN__) || defined(__ANDROID__) renderer.shaderManager.AddShaderFromFiles("env_sky", "resources/shaders/env_sky.vertex", "resources/shaders/env_sky_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("defaultAtmosphere", "resources/shaders/defaultAtmosphere.vertex", "resources/shaders/defaultAtmosphere_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetBake", "resources/shaders/planet_bake.vertex", "resources/shaders/planet_bake_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetStone", "resources/shaders/planet_stone.vertex", "resources/shaders/planet_stone_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetLand", "resources/shaders/planet_land.vertex", "resources/shaders/planet_land_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("spark", "resources/shaders/spark.vertex", "resources/shaders/spark_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning", "resources/shaders/skinning.vertex", "resources/shaders/default_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog", "resources/shaders/fog.vertex", "resources/shaders/fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning", "resources/shaders/fog_skinning_web.vertex", "resources/shaders/fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog", "resources/shaders/darklands_fog.vertex", "resources/shaders/darklands_fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog_skinning", "resources/shaders/darklands_fog_skinning.vertex", "resources/shaders/darklands_fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_flash", "resources/shaders/default.vertex", "resources/shaders/darklands_flash_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneFade", "resources/shaders/default.vertex", "resources/shaders/cutscene_fade_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneBlack", "resources/shaders/default.vertex", "resources/shaders/cutscene_black_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth_skinning", "resources/shaders/shadow_depth_skinning.vertex", "resources/shaders/shadow_depth_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default_shadow", "resources/shaders/default_shadow_web.vertex", "resources/shaders/default_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning_shadow", "resources/shaders/skinning_shadow_web.vertex", "resources/shaders/default_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_shadow", "resources/shaders/fog_shadow_web.vertex", "resources/shaders/fog_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning_shadow", "resources/shaders/fog_skinning_shadow_web.vertex", "resources/shaders/fog_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog", "resources/shaders/night_fog_web.vertex", "resources/shaders/night_fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning", "resources/shaders/night_fog_skinning_web.vertex", "resources/shaders/night_fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_shadow", "resources/shaders/night_fog_shadow_web.vertex", "resources/shaders/night_fog_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning_shadow", "resources/shaders/night_fog_skinning_shadow_web.vertex", "resources/shaders/night_fog_shadow_web.fragment", CONST_ZIP_FILE); // Instancing variants of the static-object shaders (same sources + INSTANCED define) renderer.shaderManager.AddShaderFromFiles("fog_inst", "resources/shaders/fog.vertex", "resources/shaders/fog_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("darklands_fog_inst", "resources/shaders/darklands_fog.vertex", "resources/shaders/darklands_fog_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("shadow_depth_inst", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("fog_shadow_inst", "resources/shaders/fog_shadow_web.vertex", "resources/shaders/fog_shadow_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("night_fog_inst", "resources/shaders/night_fog_web.vertex", "resources/shaders/night_fog_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("night_fog_shadow_inst", "resources/shaders/night_fog_shadow_web.vertex", "resources/shaders/night_fog_shadow_web.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("path_visualization", "resources/shaders/path_visualization.vertex", "resources/shaders/path_visualization_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("text2d", "resources/shaders/text2d.vertex", "resources/shaders/text2d_web.fragment", CONST_ZIP_FILE ); /*#elif defined(__linux__) renderer.shaderManager.AddShaderFromFiles("env_sky", "resources/shaders/env_sky.vertex", "resources/shaders/env_sky_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("defaultAtmosphere", "resources/shaders/defaultAtmosphere.vertex", "resources/shaders/defaultAtmosphere_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetBake", "resources/shaders/planet_bake.vertex", "resources/shaders/planet_bake_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetStone", "resources/shaders/planet_stone.vertex", "resources/shaders/planet_stone_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetLand", "resources/shaders/planet_land.vertex", "resources/shaders/planet_land_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("spark", "resources/shaders/spark.vertex", "resources/shaders/spark_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning", "resources/shaders/skinning.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog", "resources/shaders/fog.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning", "resources/shaders/fog_skinning.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog", "resources/shaders/darklands_fog.vertex", "resources/shaders/darklands_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog_skinning", "resources/shaders/darklands_fog_skinning.vertex", "resources/shaders/darklands_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_flash", "resources/shaders/default.vertex", "resources/shaders/darklands_flash_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneFade", "resources/shaders/default.vertex", "resources/shaders/cutscene_fade_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneBlack", "resources/shaders/default.vertex", "resources/shaders/cutscene_black_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth_skinning", "resources/shaders/shadow_depth_skinning.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default_shadow", "resources/shaders/default_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning_shadow", "resources/shaders/skinning_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_shadow", "resources/shaders/fog_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning_shadow", "resources/shaders/fog_skinning_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog", "resources/shaders/night_fog.vertex", "resources/shaders/night_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning", "resources/shaders/night_fog_skinning.vertex", "resources/shaders/night_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_shadow", "resources/shaders/night_fog_shadow.vertex", "resources/shaders/night_fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning_shadow", "resources/shaders/night_fog_skinning_shadow.vertex", "resources/shaders/night_fog_shadow_desktop.fragment", CONST_ZIP_FILE); */ #else renderer.shaderManager.AddShaderFromFiles("env_sky", "resources/shaders/env_sky.vertex", "resources/shaders/env_sky_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("defaultAtmosphere", "resources/shaders/defaultAtmosphere.vertex", "resources/shaders/defaultAtmosphere_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetBake", "resources/shaders/planet_bake.vertex", "resources/shaders/planet_bake_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetStone", "resources/shaders/planet_stone.vertex", "resources/shaders/planet_stone_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetLand", "resources/shaders/planet_land.vertex", "resources/shaders/planet_land_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("spark", "resources/shaders/spark.vertex", "resources/shaders/spark_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning", "resources/shaders/skinning.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog", "resources/shaders/fog.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning", "resources/shaders/fog_skinning.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog", "resources/shaders/darklands_fog.vertex", "resources/shaders/darklands_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_fog_skinning", "resources/shaders/darklands_fog_skinning.vertex", "resources/shaders/darklands_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("darklands_flash", "resources/shaders/default.vertex", "resources/shaders/darklands_flash_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneFade", "resources/shaders/default.vertex", "resources/shaders/cutscene_fade_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("cutsceneBlack", "resources/shaders/default.vertex", "resources/shaders/cutscene_black_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth_skinning", "resources/shaders/shadow_depth_skinning.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default_shadow", "resources/shaders/default_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning_shadow", "resources/shaders/skinning_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_shadow", "resources/shaders/fog_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning_shadow", "resources/shaders/fog_skinning_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog", "resources/shaders/night_fog.vertex", "resources/shaders/night_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning", "resources/shaders/night_fog_skinning.vertex", "resources/shaders/night_fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_shadow", "resources/shaders/night_fog_shadow.vertex", "resources/shaders/night_fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("night_fog_skinning_shadow", "resources/shaders/night_fog_skinning_shadow.vertex", "resources/shaders/night_fog_shadow_desktop.fragment", CONST_ZIP_FILE); // Instancing variants of the static-object shaders (same sources + INSTANCED define) renderer.shaderManager.AddShaderFromFiles("fog_inst", "resources/shaders/fog.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("darklands_fog_inst", "resources/shaders/darklands_fog.vertex", "resources/shaders/darklands_fog_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("shadow_depth_inst", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("fog_shadow_inst", "resources/shaders/fog_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("night_fog_inst", "resources/shaders/night_fog.vertex", "resources/shaders/night_fog_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("night_fog_shadow_inst", "resources/shaders/night_fog_shadow.vertex", "resources/shaders/night_fog_shadow_desktop.fragment", CONST_ZIP_FILE, "#define INSTANCED\n"); renderer.shaderManager.AddShaderFromFiles("path_visualization", "resources/shaders/path_visualization.vertex", "resources/shaders/path_visualization_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("text2d", "resources/shaders/text2d.vertex", "resources/shaders/text2d_desktop.fragment", CONST_ZIP_FILE ); #endif }); loadSteps.push([this]() { renderer.textureManager.LoadFromPng("resources/dialogue/portrait_aiperi.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_ghost.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_hero_neutral.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_hero_think.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_phone.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_student_boy.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_student_girl.png", CONST_ZIP_FILE); renderer.textureManager.LoadFromPng("resources/dialogue/portrait_teacher.png", CONST_ZIP_FILE); }); loadSteps.push([this]() { ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE); Character::nameTranslations.loadFromFile("resources/config/translate_names.json"); }); // Campaigns are only listed here; their locations are built on demand when the // player starts or loads one (see enterLocationThen). 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]() { // Wire phone dialogue start function so MenuManager can trigger dialogues. menuManager.startDialogueFunc = [this](const std::string& id) { if (currentLocation()) currentLocation()->dialogueSystem.startDialogue(id); }; menuManager.startDarklandsTransitionFunc = [this]() { startDarklandsTransition(); }; menuManager.startNightTransitionFunc = [this]() { startNightTransition(); }; menuManager.chatOpenCallback = [this](int chatIndex) { if (currentLocation()) currentLocation()->scriptEngine.callChatOpenCallback(chatIndex); }; menuManager.skipCutsceneFunc = [this]() { if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene(); }; }); loadSteps.push([this]() { try { menuManager.setup(CONST_ZIP_FILE); logger() << "UI loaded successfully" << std::endl; } catch (const std::exception& e) { std::cerr << "Failed to load UI: " << e.what() << std::endl; } menuManager.startGameFunc = [this](const std::string& campaignId) { const CampaignConfig* config = CampaignRegistry::instance().find(campaignId); const std::string startLocation = config ? config->startLocation : std::string(); auto start = [this, campaignId, startLocation]() { enterLocationThen(campaignId, startLocation, [this]() { performResetToInitialState(); applyTutorialSetting(); const CampaignConfig& campaign = activeCampaign(); server().submit( SetCurrentLocationCommand{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 (isLocationLoaded(campaignId, startLocation)) { // 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 load // 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); }; menuManager.onLoadGame = [this](int slot) { loadGame(slot); }; menuManager.getSlotInfoFunc = [this](int slot) { return readSlotInfo(slot); }; menuManager.shadowMapSettingsChangedFunc = [this](bool newValue) { if (newValue) { createShadowMapAndProparateToLocations(); menuManager.shadowsEnabled = true; } else { destroyShadowMapAndProparateToLocations(); menuManager.shadowsEnabled = false; } }; // Lets MenuManager run a heavy operation (e.g. reloadLocalizedGameContent()) // as a chunked sequence instead of one blocking call: the closures are // drained one per frame by update(), with the loading screen shown for // 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; // Hidden together with loadingCompleted, so no frame falls back to drawLoading(). menuManager.hideLoadingScreen(); }); loadStepsTotal = loadSteps.size(); loadStepsDone = 0; showLoadingProgressBar = false; loadingCompleted = false; }; }); loadSteps.push([this]() { if (audioPlayer->init()) { audioPlayer->setMusicVolume(100); audioPlayer->setSoundVolume(80); logger() << "Audio initialized successfully" << std::endl; } else { logger() << "Audio initialization failed" << std::endl; } }); loadSteps.push([this]() { menuManager.loadSettings(); audioPlayer->crossFadeMusicAsync("music/main menu final.ogg"); menuManager.lastMusicTrack = "music/main menu final.ogg"; }); loadSteps.push([this]() { if (menuManager.shadowsEnabled) { createShadowMapAndProparateToLocations(); } }); 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; }); loadStepsTotal = loadSteps.size(); loadStepsDone = 0; } const CampaignConfig& Game::activeCampaign() const { const auto state = stateSnapshot(); const CampaignConfig* campaign = state ? CampaignRegistry::instance().find(state->campaignId) : nullptr; if (!campaign) campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId); if (!campaign) { static const CampaignConfig empty; return empty; } return *campaign; } bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const { return player && loadedCampaignId == campaignId && gameServerHost.hasRuntimeLocation(locationId); } void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId, std::function then) { const CampaignConfig* campaign = CampaignRegistry::instance().find(requestedCampaignId); if (!campaign) { std::cerr << "[campaign] Unknown campaign '" << requestedCampaignId << "', using '" << CampaignRegistry::kDefaultCampaignId << "'" << std::endl; campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId); if (!campaign) return; } const std::string campaignId = campaign->id; const std::string locationId = campaign->findLocation(requestedLocationId) ? requestedLocationId : campaign->startLocation; if (isLocationLoaded(campaignId, locationId)) { then(); return; } // Behind the location's loading screen: release what's loaded first, then load the // new data, then continue. The screen is hidden when the sequence completes (see // runAsLoadingSequence). menuManager.showLoadingScreen(campaign->loadingScreenFor(locationId)); std::vector> steps; if (loadedCampaignId != campaignId) { steps.push_back([this]() { unloadCampaign(); }); steps.push_back([this, campaignId]() { loadCampaign(campaignId); }); } else { steps.push_back([this]() { unloadLocation(true); }); } steps.push_back([this, locationId]() { loadLocation(locationId); }); steps.push_back([then]() { then(); }); menuManager.runAsLoadingSequence(std::move(steps)); } void Game::loadCampaign(const std::string& campaignId) { loadedCampaignId = campaignId; const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId); if (!campaign) return; #if defined(NETWORK) remoteCampaignActive = campaignId == "battle_zone_multiplayer"; remoteInitialStateBound = false; if (remoteCampaignActive) { logger() << "[network] Campaign '" << campaignId << "' selected; creating WebSocket client" << std::endl; // Use "ws://127.0.0.1:8080/" for a local development server. static const char* const kMultiplayerServerUrl = "wss://api.shadowoverbishkek.com/server001/"; //static const char* const kMultiplayerServerUrl = "ws://127.0.0.1:8080/"; WebsocketEndpoint endpoint; if (!endpoint.parse(kMultiplayerServerUrl)) { logger() << "[network] Invalid multiplayer server URL: " << kMultiplayerServerUrl << std::endl; remoteCampaignActive = false; gameServer.use(internalGameServer); gameServerHost.use(localGameServerHost); return; } websocketGameClient = std::make_unique(endpoint); remoteGameServerHost = std::make_unique(*websocketGameClient); gameServer.use(*websocketGameClient); gameServerHost.use(*remoteGameServerHost); logger() << "[network] Remote game server routes installed" << std::endl; } else { logger() << "[network] Campaign '" << campaignId << "' uses the in-process server" << std::endl; gameServer.use(internalGameServer); gameServerHost.use(localGameServerHost); } #else remoteCampaignActive = false; remoteInitialStateBound = false; logger() << "[network] Networking was not compiled into this client; " "campaign '" << campaignId << "' uses the in-process server" << std::endl; #endif gameServerHost.initializeCampaign( campaignId, campaign->questsPath, CONST_ZIP_FILE); // The player stays loaded for the whole campaign; locations come and go around him. player = Location::createPlayer( renderer, audioPlayer.get(), server(), server().localPlayerId()); if (player) player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; } void Game::unloadCampaign() { unloadLocation(false); player.reset(); loadedCampaignId.clear(); gameServerHost.clearCampaign(); #if defined(NETWORK) gameServer.use(internalGameServer); gameServerHost.use(localGameServerHost); remoteGameServerHost.reset(); websocketGameClient.reset(); #endif remoteCampaignActive = false; remoteInitialStateBound = false; renderer.textureManager.UnloadUnused(); // the player's textures } void Game::loadLocation(const std::string& locationId) { const LocationConfig* config = activeCampaign().findLocation(locationId); if (!config) { const auto state = stateSnapshot(); std::cerr << "[campaign] Campaign '" << (state ? state->campaignId : std::string()) << "' has no location '" << locationId << "'" << std::endl; return; } SDL_PumpEvents(); const bool initializeLocation = gameServerHost.prepareLocation(*config); std::shared_ptr loc = createLocation(*config); wireLocation(loc.get()); if (shadowMap) loc->shadowMap = shadowMap; if (initializeLocation) loc->scriptEngine.callLocationInitCallback(); // Back to how the player left it (or to the start state's snapshot of it). gameServerHost.bindRuntimeLocation(locationId, loc); } void Game::unloadLocation(bool storeState) { gameServerHost.clearRuntimeLocations(storeState); // Textures only the location's models and characters used are freed now. renderer.textureManager.UnloadUnused(); } void Game::teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY) { logger() << "[TELEPORT] " << " -> " << destName << std::endl; if (!activeCampaign().findLocation(destName)) { std::cerr << "[TELEPORT] Unknown destination location: " << destName << std::endl; return; } if (currentLocation()) { currentLocation()->scriptEngine.callLocationExitCallback(); currentLocation()->dialogueSystem.stopDialogue(); } // Runs from inside the current location's update(); the location itself is // released by a later loading step. const auto state = stateSnapshot(); if (!state) return; enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() { server().submit(SetCurrentLocationCommand{destName}); Location* loc = currentLocation(); if (!loc) return; if (loc->player) { server().submit(SetCharacterPositionCommand{ destName, loc->player->entityId(), destPos}); server().submit(SetCharacterFacingAngleCommand{ destName, loc->player->entityId(), destRotY, true}); loc->stopCharacter(*loc->player); } loc->localState().cameraAzimuth = destRotY; loc->scriptEngine.callLocationEnterCallback(); //Reset taxi server().submit(ResetTaxiCommand{destName}); server().submit(SetTaxiCalledCommand{false}); menuManager.onLocationChanged(destName); }); } void Game::performFullReset() { // Rebuild the loaded location from its config; the others have no stored state // any more, so they are built fresh when the player first goes there. std::vector loadedIds = gameServerHost.loadedRuntimeLocationIds(); gameServerHost.resetCampaign( activeCampaign().questsPath, CONST_ZIP_FILE); unloadLocation(false); for (const std::string& id : loadedIds) loadLocation(id); // Developer workflow: uncomment to (re)generate the campaign's start-state JSON // from this freshly-rebuilt state after changing gameplay/content JSON or Lua. // Re-comment before shipping. //saveInitialState(); } void Game::performResetToInitialState() { const std::string path = activeCampaign().startStatePath; std::vector fileData; if (strlen(CONST_ZIP_FILE) > 0) { fileData = readFileFromZIP(path, CONST_ZIP_FILE); } else { fileData = readFile(path); } std::string content(fileData.begin(), fileData.end()); //const std::vector contentVector = FRG::readFileFromZIP(); //const std::string content = FRG::readTextFile(path); if (!content.empty()) { try { nlohmann::json root = nlohmann::json::parse(content); gameServerHost.load(root); // applies to the loaded location in place and stores // the other locations' states for when they load. return; } catch (const std::exception& e) { logger() << "[reset] Failed to parse " << path << ": " << e.what() << " -- falling back to full rebuild" << std::endl; } } else { logger() << "File " << path << " is empty -- falling back to full rebuild" << std::endl; } performFullReset(); } // Runs after either reset path. Start states always hold the start of the tutorial // (if the campaign has one); a disabled tutorial is simply one that is already completed. void Game::applyTutorialSetting() { if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting) server().submit( SetTutorialStepCommand{TutorialStep::Completed}); } void Game::saveInitialState() { nlohmann::json root; gameServerHost.save(root); // Written next to the executable; copy it over the campaign's start-state file. const std::string startStatePath = activeCampaign().startStatePath; const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1); logger() << "[reset] saveInitialState called, to " << path << std::endl; std::ofstream file(path); if (file.is_open()) { file << root.dump(2); logger() << "[reset] Saved initial state snapshot to " << path << std::endl; } else { std::cerr << "[reset] Could not open " << path << " for writing" << std::endl; } saveJsonToFile(root, path); } std::shared_ptr Game::createLocation(const LocationConfig& config) { LocationSetup params; params.gameObjectsJsonPath = config.gameObjectsJsonPath; params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath; params.npcsJsonPath = config.npcsJsonPath; params.dialoguesJsonPath = config.dialoguesJsonPath; //params.navigationJsonPaths = config.navigationJsonPaths; params.scriptPath = config.scriptPath; params.teleportsJsonPath = config.teleportsJsonPath; params.triggerZonesJsonPath = config.triggerZonesJsonPath; params.lightsJsonPath = config.lightsJsonPath; params.playerPosition = config.playerPosition; params.player = player; auto loc = std::make_shared( renderer, server(), config.id); gameServerHost.setupRuntimeLocation( config.id, loc, std::move(params), audioPlayer.get()); loc->requestNightDayTransition = [this](bool isNight, bool isDawn) { const auto state = stateSnapshot(); if (!state) return; server().submit(SetWorldModeCommand{ state->isDarklands, isNight, isDawn}); }; loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; loc->requestReturnToMainMenu = [this]() { server().submit(SetCurrentLocationCommand{std::string()}); menuManager.showMenuAfterGameCompleted(); }; loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { menuManager.setChatUnread(idx, unread, msg); }; for (auto& npc : loc->npcs) { auto state = npc ? npc->stateSnapshot() : nullptr; if (state && state->canAttack) { npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); }; } } loc->requestDarklandsPlayBattleMusic = [this]() { audioPlayer->crossFadeMusicFromPositionAsync("music/bishkek fight.ogg"); menuManager.lastMusicTrack = "music/bishkek fight.ogg"; }; loc->requestDarklandsPlayNormalMusic = [this]() { audioPlayer->crossFadeMusicFromPositionAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; }; return loc; } void Game::wireLocation(Location* loc) { loc->onPlayerTaxiRequired = [this]() { menuManager.tutorialShowTaxiHint(); }; // Teleport zones: destination name and position come from the teleport zone data. loc->onTeleport = [this](const std::string& destName, const Eigen::Vector3f& destPos, float destRotY) { teleportPlayer(destName, destPos, destRotY); }; // Wire tutorial advance callbacks. // completeTutorialStep() only acts on the current step, so sharing is safe. { loc->dialogueSystem.setOnDialogueAdvanced([this]() { server().submit( CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue}); }); loc->onPlayerFloorWalk = [this]() { server().submit( CompleteTutorialStepCommand{TutorialStep::Walk}); menuManager.onPlayerStartedWalking(); }; } // Wire chat-bubble callback so dynamic bubbles appear as dialogue lines are shown. { loc->dialogueSystem.setOnChatBubbleReady([this](const std::string& text, bool incoming) { menuManager.onChatBubbleReady(text, incoming); }); } // Wire cutscene HUD: show skip button when cutscene starts, restore HUD when it ends. { loc->dialogueSystem.setOnCutsceneStarted([this]() { menuManager.onCutsceneStarted(); }); loc->dialogueSystem.setOnCutsceneFinishedExtra([this](const std::string&) { menuManager.onCutsceneFinished(); }); } // Wire HP-change callbacks so the player updates the health bar HUD. { if (loc->player) { // Raw pointer: the player belongs to the location (and outlives it), so a // shared_ptr here would keep unloaded locations alive. Rewired on every load. loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) { menuManager.updateHealthBar(hp, maxHp); if (!attackerId.empty()) { //Someone attacked, cancel dialogue and agro the player if (loc->dialogueSystem.isDialogueActive()) { loc->dialogueSystem.stopDialogue(); } bool currentTargetDead = false; auto playerState = loc->player->stateSnapshot(); if (!playerState) return; for (const auto& npc : loc->npcs) { if (npc && npc->entityId() == playerState->attackTargetId) { auto npcState = npc->stateSnapshot(); currentTargetDead = !npcState || npcState->hp <= 0.f; break; } } if ((playerState->attackTargetId.empty() || currentTargetDead) && loc->player->isMoving() == false && loc->player->getHp() > 0) { server().submit(SetCharacterTargetCommand{ loc->locationId(), server().localPlayerId(), attackerId}); } } }; } } } void Game::createShadowMapAndProparateToLocations() { if (shadowMap) { logger() << "Shadow map already exists, skipping creation" << std::endl; return; } CheckGlError(__FILE__, __LINE__); shadowMap = std::make_shared(2048, 40.0f, 0.1f, 100.0f); CheckGlError(__FILE__, __LINE__); shadowMap->setLightDirection(Eigen::Vector3f(-0.5f, -1.0f, -0.3f)); CheckGlError(__FILE__, __LINE__); logger() << "Shadow map initialized" << std::endl; for (const std::string& id : gameServerHost.loadedRuntimeLocationIds()) if (Location* loc = gameServerHost.runtimeLocation(id)) loc->shadowMap = shadowMap; } void Game::destroyShadowMapAndProparateToLocations() { CheckGlError(__FILE__, __LINE__); shadowMap.reset(); CheckGlError(__FILE__, __LINE__); for (const std::string& id : gameServerHost.loadedRuntimeLocationIds()) if (Location* loc = gameServerHost.runtimeLocation(id)) loc->shadowMap.reset(); CheckGlError(__FILE__, __LINE__); } void Game::drawUI() { glClear(GL_DEPTH_BUFFER_BIT); glDisable(GL_DEPTH_TEST); glDepthMask(GL_FALSE); renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); glEnable(GL_BLEND); if (!menuManager.cutsceneHudActive_) menuManager.uiManager.draw(renderer); menuManager.drawChatFbo(renderer); if (currentLocation()) { currentLocation()->dialogueSystem.draw(renderer); } glEnable(GL_BLEND); menuManager.topUiManager.draw(renderer); glDisable(GL_BLEND); renderer.shaderManager.PopShader(); glDepthMask(GL_TRUE); glEnable(GL_DEPTH_TEST); CheckGlError(__FILE__, __LINE__); } // 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) { if (menuManager.isLoadingScreenShown()) drawLoadingScreenUi(); else drawLoading(); } /*else if (Environment::width < Environment::height) { drawMobilePortraitOverlay(); }*/ else { Location* loc = currentLocation(); if (loc) { const auto state = stateSnapshot(); if (state && state->isDarklands) { loc->drawGameDarklands(); CheckGlError(__FILE__, __LINE__); } else if (state && state->isNight) { loc->drawGameNight(); CheckGlError(__FILE__, __LINE__); } else if (loc->shadowMap) { CheckGlError(__FILE__, __LINE__); loc->drawShadowDepthPass(); CheckGlError(__FILE__, __LINE__); loc->drawGameWithShadows(); CheckGlError(__FILE__, __LINE__); } else { loc->drawGame(); CheckGlError(__FILE__, __LINE__); } } drawUI(); drawDarklandsFlash(); } CheckGlError(__FILE__, __LINE__); } void Game::drawLoading() { glClear(GL_DEPTH_BUFFER_BIT); renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform1f("uAlpha", 1.0); float width = Environment::projectionWidth; float height = Environment::projectionHeight; renderer.PushProjectionMatrix( -width * 0.5f, width * 0.5f, -height * 0.5f, height * 0.5f, -10, 10); renderer.PushMatrix(); renderer.LoadIdentity(); if (showLoadingProgressBar == false) // Loading language screen { if (FRG::g_currentLanguage == FRG::Language::English) { glBindTexture(GL_TEXTURE_2D, menuManager.languageLoadingEn->getTexID()); } else if (FRG::g_currentLanguage == FRG::Language::Russian) { glBindTexture(GL_TEXTURE_2D, menuManager.languageLoadingRu->getTexID()); } else { glBindTexture(GL_TEXTURE_2D, loadingTexture->getTexID()); } } else { glBindTexture(GL_TEXTURE_2D, loadingTexture->getTexID()); } renderer.DrawVertexRenderStruct(loadingMesh); renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); if (showLoadingProgressBar && loadingProgressBarFrameTexture && loadingProgressBarTexture) { const float W = Environment::projectionWidth; const float H = Environment::projectionHeight; static constexpr float kBarW = 400.0f; static constexpr float kBarH = 40.0f; const float barCenterY = -(H * 0.4f - 60.0f); const float barLeft = -kBarW * 0.5f; const float barBottom = barCenterY - kBarH * 0.5f; const float barTop = barCenterY + kBarH * 0.5f; // Rebuild frame mesh when projection dimensions change if (W != loadingBarLastW || H != loadingBarLastH) { loadingBarLastW = W; loadingBarLastH = H; VertexDataStruct frameData; frameData.PositionData = { {barLeft, barBottom, 4.f}, {barLeft, barTop, 4.f}, {barLeft+kBarW, barTop, 4.f}, {barLeft+kBarW, barTop, 4.f}, {barLeft+kBarW, barBottom, 4.f}, {barLeft, barBottom, 4.f} }; frameData.TexCoordData = { {0.f,0.f},{0.f,1.f},{1.f,1.f}, {1.f,1.f},{1.f,0.f},{0.f,0.f} }; loadingProgressBarFrameMesh.data = std::move(frameData); loadingProgressBarFrameMesh.RefreshVBO(); loadingBarLastRenderedProgress = -1.0f; // force fill rebuild too } // Rebuild fill mesh when progress changes const float p = (std::min)(currentLoadingProgress(), 1.0f); if (p != loadingBarLastRenderedProgress) { loadingBarLastRenderedProgress = p; if (p > 0.001f) { const float fillRight = barLeft + kBarW * p; VertexDataStruct fillData; fillData.PositionData = { {barLeft, barBottom, 4.f}, {barLeft, barTop, 4.f}, {fillRight, barTop, 4.f}, {fillRight, barTop, 4.f}, {fillRight, barBottom, 4.f}, {barLeft, barBottom, 4.f} }; fillData.TexCoordData = { {0.f,0.f},{0.f,1.f},{p,1.f}, {p,1.f},{p,0.f},{0.f,0.f} }; loadingProgressBarFillMesh.data = std::move(fillData); loadingProgressBarFillMesh.RefreshVBO(); } } renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform1f("uAlpha", 1.0f); renderer.PushProjectionMatrix(-W*0.5f, W*0.5f, -H*0.5f, H*0.5f, -10, 10); renderer.PushMatrix(); renderer.LoadIdentity(); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glBindTexture(GL_TEXTURE_2D, loadingProgressBarFrameTexture->getTexID()); renderer.DrawVertexRenderStruct(loadingProgressBarFrameMesh); if (p > 0.001f) { glBindTexture(GL_TEXTURE_2D, loadingProgressBarTexture->getTexID()); renderer.DrawVertexRenderStruct(loadingProgressBarFillMesh); } renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); } CheckGlError(__FILE__, __LINE__); } int64_t Game::getSyncTimeMs() { int64_t localNow = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(); return localNow; } void Game::processTickCount() { if (lastTickCount == 0) { lastTickCount = getSyncTimeMs(); return; } const int64_t now = getSyncTimeMs(); const int64_t delta = (std::min)(now - lastTickCount, static_cast(CONST_MAX_TIME_INTERVAL)); lastTickCount = now; if (delta > 0) { 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)); if (loadingCompleted) { if (remoteCampaignActive) { // Remote authoritative time comes only from contiguous server ticks. gameServerHost.update(delta); #if defined(NETWORK) // The connection-assigned slot is known only after initial_state. // Always rebuild once: replica_.load() replaces authoritative // location state even when the assigned ID remains "player". if (websocketGameClient && websocketGameClient->initialized() && (!remoteInitialStateBound || !player || player->entityId() != server().localPlayerId())) { const auto assignedState = stateSnapshot(); const std::string locationId = assignedState ? assignedState->currentLocationId : std::string(); unloadLocation(false); player = Location::createPlayer( renderer, audioPlayer.get(), server(), server().localPlayerId()); if (player) player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; if (!locationId.empty()) loadLocation(locationId); remoteInitialStateBound = true; logger() << "[network] Runtime rebound to assigned player '" << server().localPlayerId() << "'" << std::endl; } #endif } else { simulationAccumulatorMs += delta; while (simulationAccumulatorMs >= kSimulationTickMs) { gameServerHost.update(kSimulationTickMs); simulationAccumulatorMs -= kSimulationTickMs; } } if (Location* location = currentLocation()) location->updatePresentation(delta); #if defined(NETWORK) if (remoteCampaignActive && websocketGameClient && websocketGameClient->connectionFailed()) { logger() << "[network] Connection failed; returning to campaign selection" << std::endl; unloadCampaign(); menuManager.showMainMenu(); menuManager.showCampaignScreen(); return; } #endif processServerEvents(); } } } void Game::processServerEvents() { for (GameServerEvent& event : server().drainEvents()) { std::visit([this](const auto& value) { using T = std::decay_t; if constexpr (std::is_same_v) { menuManager.onItemPickedUp(value.itemId); const Item* item = ItemRegistry::instance().findById(value.itemId); if (item) menuManager.showToast( menuManager.toastTextureItemAdded, item->name); audioPlayer->playSoundAsync( "resources/audio/831642__ienba__generic-game-pick-up.ogg"); } else if constexpr (std::is_same_v) { const Item* item = ItemRegistry::instance().findById(value.itemId); if (item) menuManager.showToast( menuManager.toastTextureItemRemoved, item->name); audioPlayer->playSoundAsync( "resources/audio/831642__ienba__generic-game-pick-up.ogg"); } else if constexpr (std::is_same_v) { const auto state = stateSnapshot(); if (!state) return; auto quest = std::find_if( state->quests.begin(), state->quests.end(), [&value](const Quest::QuestState& candidate) { return candidate.definition.id == value.questId; }); if (quest == state->quests.end()) return; switch (value.type) { case Quest::QuestEventType::Unlocked: menuManager.showToast( menuManager.toastTextureQuestNew, quest->definition.title); break; case Quest::QuestEventType::Completed: menuManager.showToast( menuManager.toastTextureQuestCompleted, quest->definition.title); break; case Quest::QuestEventType::Failed: menuManager.showToast( menuManager.toastTextureQuestFailed, quest->definition.title); break; case Quest::QuestEventType::ObjectiveCompleted: break; } } }, event); } } void Game::render() { FRG::CheckGlError(__FILE__, __LINE__); glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); drawScene(); processTickCount(); SDL_GL_SwapWindow(FRG::Environment::window); } void Game::onWindowResized() { const bool wasPortrait = Environment::width < Environment::height; if (Environment::isHighDPIEnabled) { int drawW, drawH; SDL_GL_GetDrawableSize(Environment::window, &drawW, &drawH); Environment::width = drawW; Environment::height = drawH; } else { int winW, winH; SDL_GetWindowSize(Environment::window, &winW, &winH); Environment::width = winW / FRG::Environment::customDpiScale; Environment::height = winH / FRG::Environment::customDpiScale; } Environment::computeProjectionDimensions(); const bool isPortrait = Environment::width < Environment::height; menuManager.uiManager.updateAllLayouts(); menuManager.topUiManager.updateAllLayouts(); menuManager.loadingUiManager.updateAllLayouts(); if (wasPortrait != isPortrait) { menuManager.onOrientationChanged(); } menuManager.refreshFullScreenToggleButton(); logger() << "Window resized (Render Size): " << Environment::width << "x" << Environment::height << std::endl; } void Game::update() { processServerEvents(); SDL_Event event; const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height); while (SDL_PollEvent(&event)) { if (event.type == SDL_QUIT) { Environment::exitGameLoop = true; } #ifdef __ANDROID__ if (event.type == SDL_APP_WILLENTERBACKGROUND) { isAppActive = false; __android_log_print(ANDROID_LOG_INFO, "Game", "App moving to background, suspending loop"); } if (event.type == SDL_APP_DIDENTERFOREGROUND) { isAppActive = true; __android_log_print(ANDROID_LOG_INFO, "Game", "App returned to foreground, resuming loop"); } #endif if (event.type == SDL_WINDOWEVENT && (event.window.event == SDL_WINDOWEVENT_RESIZED || event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED)) { onWindowResized(); } // Suppress all input while portrait overlay is shown //if (mobileIsPortrait) continue; /* #ifdef __ANDROID__ __android_log_print(ANDROID_LOG_ERROR, "Game", "Game::update step 2 event type: %d", event.type); #endif */ // Ignore pointer input until the initial loading screen has finished. // Otherwise a click on the loading screen (e.g. at the spot where a // main-menu button will later be) gets delivered to the menu UI as // soon as it appears underneath, triggering that button unintentionally. if (!loadingCompleted && (event.type == SDL_FINGERDOWN || event.type == SDL_FINGERUP || event.type == SDL_FINGERMOTION || event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP)) { continue; } /* #ifdef __ANDROID__ __android_log_print(ANDROID_LOG_ERROR, "Game", "Game::update step 3 event type: %d", event.type); #endif */ #ifdef __ANDROID__ if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_AC_BACK) { Environment::exitGameLoop = true; } #endif // Touch events (real fingers on Android and on mobile browsers via Emscripten). // SDL_HINT_TOUCH_MOUSE_EVENTS="0" is set in main.cpp so these do not // also fire SDL_MOUSEBUTTONDOWN, preventing double-processing. if (event.type == SDL_FINGERDOWN || event.type == SDL_FINGERUP || event.type == SDL_FINGERMOTION) { /* #ifdef __ANDROID__ __android_log_print(ANDROID_LOG_ERROR, "Game", "Game::update step 4 event type: %d", event.type); #endif */ int eventX = static_cast(event.tfinger.x * Environment::width); int eventY = static_cast(event.tfinger.y * Environment::height); int mx = static_cast(event.tfinger.x * Environment::projectionWidth); int my = static_cast(event.tfinger.y * Environment::projectionHeight); int64_t fingerId = static_cast(event.tfinger.fingerId); if (event.type == SDL_FINGERDOWN) { onPointerDown(fingerId, eventX, eventY, mx, my); } else if (event.type == SDL_FINGERUP) { onPointerUp(fingerId, eventX, eventY, mx, my); } else { onPointerMotion(fingerId, eventX, eventY, mx, my); } continue; } /* #ifdef __ANDROID__ __android_log_print(ANDROID_LOG_ERROR, "Game", "Game::update step 5 event type: %d", event.type); #endif */ // Mouse-left maps to a single virtual touch with MOUSE_FINGER_ID. // Right mouse is intentionally unused now — camera rotation is on hold-and-drag. if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { if (event.button.button == SDL_BUTTON_LEFT) { int eventX = event.button.x; int eventY = event.button.y; int mx = static_cast((float)eventX / Environment::width * Environment::projectionWidth); int my = static_cast((float)eventY / Environment::height * Environment::projectionHeight); if (event.type == SDL_MOUSEBUTTONDOWN) { onPointerDown(FRG::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } else { onPointerUp(FRG::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } } else if (event.button.button == SDL_BUTTON_RIGHT && event.type == SDL_MOUSEBUTTONUP && currentLocation() && currentLocation()->editorMode == EditorMode::Navigation ) { currentLocation()->editor.handleRightClick(); } } else if (event.type == SDL_MOUSEMOTION) { int eventX = event.motion.x; int eventY = event.motion.y; int mx = static_cast((float)eventX / Environment::width * Environment::projectionWidth); int my = static_cast((float)eventY / Environment::height * Environment::projectionHeight); onPointerMotion(FRG::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } if (event.type == SDL_MOUSEWHEEL) { static const float zoomstep = 2.0f; if (event.wheel.y > 0) { Environment::zoom -= zoomstep; } else if (event.wheel.y < 0) { Environment::zoom += zoomstep; } if (Environment::zoom < zoomMin) { Environment::zoom = zoomMin; } if (Environment::zoom > zoomMax) { Environment::zoom = zoomMax; } //logger() << "Current zoom: " << Environment::zoom << std::endl; // Tutorial: any mouse-wheel scroll counts as the "zoom gesture". server().submit( CompleteTutorialStepCommand{TutorialStep::Zoom}); } if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { switch (event.key.keysym.sym) { default: break; } } // Обработка ввода текста if (event.type == SDL_KEYDOWN) { if (event.key.keysym.sym == SDLK_BACKSPACE) { //menuManager.uiManager.onKeyBackspace(); } if (event.key.keysym.sym == SDLK_1) { currentLocation()->switchNavigation(0); //startNightTransition(); } if (event.key.keysym.sym == SDLK_2) { currentLocation()->switchNavigation(1); //startDarklandsTransition(); } if (event.key.keysym.sym == SDLK_3) { currentLocation()->switchNavigation(2); } if (event.key.keysym.sym == SDLK_n) { if (currentLocation()->editorMode == EditorMode::None) { currentLocation()->editorMode = EditorMode::Navigation; } else { currentLocation()->editorMode = EditorMode::None; } } } if (event.type == SDL_TEXTINPUT) { // Пропускаем ctrl+c и другие команды if ((event.text.text[0] & 0x80) == 0) { // ASCII символы //menuManager.uiManager.onKeyPress((unsigned char)event.text.text[0]); } } } // Fallback detection (proactive check) int drawableW, drawableH; SDL_GL_GetDrawableSize(Environment::window, &drawableW, &drawableH); if (drawableW != lastDrawableW || drawableH != lastDrawableH) { lastDrawableW = drawableW; lastDrawableH = drawableH; onWindowResized(); } #ifdef __ANDROID__ if (!isAppActive) return; #endif // Drain one loading step per frame so the window/tab keeps pumping events // and repainting between steps instead of freezing for the whole load. if (!loadingCompleted && !loadSteps.empty()) { loadSteps.front()(); loadSteps.pop(); loadStepsDone++; } render(); mainThreadHandler.processMainThreadTasks(); } int Game::countNonUiPointers() const { int n = 0; for (const auto& kv : activePointers) { if (!kv.second.capturedByUi) ++n; } return n; } void Game::enterCameraDragMode(int eventX, int eventY) { cameraDragging = true; if (currentLocation()) { currentLocation()->cameraDragging = true; // Anchor on the *current* position, not the original press, so the // camera doesn't snap by however far the finger drifted before the // movement threshold was crossed. currentLocation()->lastMouseX = eventX; currentLocation()->lastMouseY = eventY; // Snapshot current angles so we can measure how far the user rotates. const LocationLocalState& state = currentLocation()->localState(); dragStartAzimuth = state.cameraAzimuth; dragStartInclination = state.cameraInclination; } } void Game::exitCameraDragMode() { cameraDragging = false; if (currentLocation()) { currentLocation()->cameraDragging = false; } } void Game::startPinch() { // Pick the first two non-UI pointers as pinch anchors. bool foundA = false, foundB = false; for (auto& kv : activePointers) { if (kv.second.capturedByUi) continue; if (!foundA) { pinchFingerA = kv.first; foundA = true; } else if (!foundB) { pinchFingerB = kv.first; foundB = true; break; } } if (!foundA || !foundB) return; const PointerState& a = activePointers[pinchFingerA]; const PointerState& b = activePointers[pinchFingerB]; float dx = static_cast(a.eventX - b.eventX); float dy = static_cast(a.eventY - b.eventY); pinchStartDistance = std::sqrt(dx * dx + dy * dy); pinchStartZoom = Environment::zoom; pinchActive = true; } void Game::updatePinchZoom() { auto itA = activePointers.find(pinchFingerA); auto itB = activePointers.find(pinchFingerB); if (itA == activePointers.end() || itB == activePointers.end()) return; if (pinchStartDistance <= 1.0f) return; float dx = static_cast(itA->second.eventX - itB->second.eventX); float dy = static_cast(itA->second.eventY - itB->second.eventY); float dist = std::sqrt(dx * dx + dy * dy); if (dist <= 1.0f) return; float newZoom = pinchStartZoom * (pinchStartDistance / dist); // Match the wheel-zoom lower bound so both gestures clamp the same way. if (newZoom < zoomMin) newZoom = zoomMin; if (newZoom > zoomMax) newZoom = zoomMax; Environment::zoom = newZoom; // Tutorial: detect a significant pinch-zoom (≥ 2 zoom units). if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) { server().submit( CompleteTutorialStepCommand{TutorialStep::Zoom}); } } void Game::endPinch() { pinchActive = false; pinchStartDistance = 0.0f; pinchStartZoom = 0.0f; } void Game::onPointerDown(int64_t fingerId, int eventX, int eventY, int mx, int my) { PointerState st; st.eventX = st.downEventX = eventX; st.eventY = st.downEventY = eventY; st.mx = st.downMx = mx; st.my = st.downMy = my; const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.topUiManager.onTouchDown(fingerId, uiX, uiY); const bool capturedByTopUi = menuManager.topUiManager.isUiInteractionForFinger(fingerId); const bool dialogueActive = currentLocation() && currentLocation()->dialogueSystem.isActive(); if (!dialogueActive && !capturedByTopUi) { menuManager.uiManager.onTouchDown(fingerId, uiX, uiY); } st.capturedByUi = capturedByTopUi || (!dialogueActive && menuManager.uiManager.isUiInteractionForFinger(fingerId)); activePointers[fingerId] = st; if (st.capturedByUi) { // UI button / slider / text-field grabbed this press; don't drive // gameplay or pinch from it. return; } if (countNonUiPointers() >= 2) { // Second finger landed on the gameplay area: switch to pinch-zoom // and abandon any in-flight tap/camera-drag. if (cameraDragging) { exitCameraDragMode(); } hasPrimaryPointer = false; if (!pinchActive) { startPinch(); } } else { hasPrimaryPointer = true; primaryPointerId = fingerId; } } void Game::onPointerUp(int64_t fingerId, int eventX, int eventY, int mx, int my) { const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.topUiManager.onTouchUp(fingerId, uiX, uiY); const bool dialogueActive = currentLocation() && currentLocation()->dialogueSystem.isActive(); if (!dialogueActive) { menuManager.uiManager.onTouchUp(fingerId, uiX, uiY); } auto it = activePointers.find(fingerId); if (it == activePointers.end()) return; PointerState st = it->second; activePointers.erase(it); // Pinch ends as soon as either anchor is lifted. The remaining finger, // if any, is *not* promoted back to a primary tap — the user was mid- // gesture, not starting a fresh press. if (pinchActive && (fingerId == pinchFingerA || fingerId == pinchFingerB)) { endPinch(); return; } if (!hasPrimaryPointer || fingerId != primaryPointerId) { return; } const bool wasDragging = cameraDragging; if (cameraDragging) { exitCameraDragMode(); } hasPrimaryPointer = false; if (st.capturedByUi || wasDragging) { // UI handled the press, or it was a camera-rotation drag — no tap action. return; } // Tap → walk-to / interact, using the original press coords so the target // isn't shifted by tiny finger drift before release. if (currentLocation()) { currentLocation()->handleDown(fingerId, st.downEventX, st.downEventY, st.downMx, st.downMy); } } void Game::onPointerMotion(int64_t fingerId, int eventX, int eventY, int mx, int my) { const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.topUiManager.onTouchMove(fingerId, uiX, uiY); const bool dialogueActive = currentLocation() && currentLocation()->dialogueSystem.isActive(); if (!dialogueActive) { menuManager.uiManager.onTouchMove(fingerId, uiX, uiY); } auto it = activePointers.find(fingerId); if (it != activePointers.end()) { it->second.eventX = eventX; it->second.eventY = eventY; it->second.mx = mx; it->second.my = my; } if (pinchActive) { updatePinchZoom(); return; } // Only the primary, non-UI-captured pointer can promote itself into a // camera-rotation drag once it crosses the movement threshold. if (hasPrimaryPointer && fingerId == primaryPointerId && it != activePointers.end() && !it->second.capturedByUi && !cameraDragging) { int dx = mx - it->second.downMx; int dy = my - it->second.downMy; if (dx * dx + dy * dy >= CAMERA_DRAG_PIXEL_THRESHOLD * CAMERA_DRAG_PIXEL_THRESHOLD) { enterCameraDragMode(eventX, eventY); } } if (currentLocation()) { // Forwarded for dialogue hover and (when cameraDragging) camera rotation. currentLocation()->handleMotion(fingerId, eventX, eventY, mx, my); // Tutorial: detect a significant camera rotation on BOTH axes. // ~0.15 rad (≈8.6°) per axis ensures the user intentionally panned in 2D, // not just nudged a single axis by accident. static constexpr float TUTORIAL_ROTATION_THRESHOLD = 0.15f; const auto game = stateSnapshot(); if (cameraDragging && game && game->tutorialStep == TutorialStep::RotateCamera) { const LocationLocalState& state = currentLocation()->localState(); float deltaAz = std::abs(state.cameraAzimuth - dragStartAzimuth); float deltaInc = std::abs(state.cameraInclination - dragStartInclination); if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) { server().submit( CompleteTutorialStepCommand{TutorialStep::RotateCamera}); } } } } bool Game::startDarklandsTransition() { const auto state = stateSnapshot(); if (!state || !state->isNight) { currentLocation()->dialogueSystem.startDialogue("darklands_day_dialog001"); return false; } if (state->isDawn) { currentLocation()->dialogueSystem.startDialogue("darklands_morning_dialog001"); return false; } audioPlayer->playSoundAsync("resources/audio/459618__n-lerche__blow.ogg"); if (darklandsFlashActive) return false; darklandsFlashActive = true; darklandsFlashFadingIn = true; darklandsFlashAlpha = 0.0f; isNightTransition = false; return true; } bool Game::startNightTransition() { if (darklandsFlashActive) return false; darklandsFlashActive = true; darklandsFlashFadingIn = true; darklandsFlashAlpha = 0.0f; isNightTransition = true; return true; } void Game::updateDarklandsFlash(int64_t deltaMs) { if (!darklandsFlashActive) return; static constexpr float kFadeDurationMs = 500.0f; const float step = static_cast(deltaMs) / kFadeDurationMs; if (darklandsFlashFadingIn) { darklandsFlashAlpha = min(darklandsFlashAlpha + step, 1.0f); if (darklandsFlashAlpha >= 1.0f) { const auto before = stateSnapshot(); if (!before) return; if (isNightTransition) { server().submit(SetWorldModeCommand{ before->isDarklands, !before->isNight, before->isDawn}); if (currentLocation()) { currentLocation()->scriptEngine.callTriggerNightEnterCallback(); } audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg"); menuManager.lastMusicTrack = "music/bishkek night.ogg"; } else { server().submit(SetWorldModeCommand{ !before->isDarklands, before->isNight, before->isDawn}); const auto after = stateSnapshot(); if (!after) return; // Change HUD menuManager.setDarklandsMode(after->isDarklands); if (after->isDarklands) { audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; } else { audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg"); menuManager.lastMusicTrack = "music/bishkek night.ogg"; } if (currentLocation()) { if (after->isDarklands) currentLocation()->scriptEngine.callDarklandsEnterCallback(); else currentLocation()->scriptEngine.callDarklandsExitCallback(); } } darklandsFlashFadingIn = false; } } else { darklandsFlashAlpha = max(darklandsFlashAlpha - step, 0.0f); if (darklandsFlashAlpha <= 0.0f) { darklandsFlashAlpha = 0.0f; darklandsFlashActive = false; isNightTransition = false; } } } void Game::drawDarklandsFlash() { if (darklandsFlashAlpha <= 0.001f) return; const float W = static_cast(Environment::projectionWidth); const float H = static_cast(Environment::projectionHeight); if (darklandsFlashQuadW != W || darklandsFlashQuadH != H) { darklandsFlashQuadW = W; darklandsFlashQuadH = H; VertexDataStruct data; data.PositionData = { {0.f, 0.f, 0.f}, {0.f, H, 0.f}, {W, H, 0.f}, {W, H, 0.f}, {W, 0.f, 0.f}, {0.f, 0.f, 0.f} }; data.TexCoordData = { {0.f, 0.f}, {0.f, 1.f}, {1.f, 1.f}, {1.f, 1.f}, {1.f, 0.f}, {0.f, 0.f} }; darklandsFlashQuad.data = std::move(data); darklandsFlashQuad.RefreshVBO(); } glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); renderer.shaderManager.PushShader("darklands_flash"); renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); renderer.RenderUniform1f("uAlpha", darklandsFlashAlpha); static const float kWhite[3] = { 1.f, 1.f, 1.f }; static const float kBlack[3] = { 0.f, 0.f, 0.f }; renderer.RenderUniform3fv("uFlashColor", isNightTransition ? kBlack : kWhite); renderer.DrawVertexRenderStruct(darklandsFlashQuad); renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); glDisable(GL_BLEND); } SaveSlotInfo Game::readSlotInfo(int slot) const { std::string path = "save_slot" + std::to_string(slot) + ".json"; std::string content = FRG::readSavedTextFile(path); if (content.empty()) return {}; try { nlohmann::json root = nlohmann::json::parse(content); if (root.value("persistenceVersion", -1) != kSinglePlayerPersistenceVersion) return {}; const auto& networkState = root.at("networkState"); if (networkState.value("schemaVersion", -1) != GameState::kSchemaVersion) return {}; SaveSlotInfo info; info.empty = false; info.campaignId = networkState.at("campaignId").get(); info.locationName = networkState.at("currentLocationId").get(); info.savedAt = networkState.value("savedAt", ""); return info; } catch (...) { return {}; } } void Game::saveGame(int slot) { nlohmann::json root; gameServerHost.save(root); std::string path = "save_slot" + std::to_string(slot) + ".json"; /*std::ofstream file(path); if (file.is_open()) { file << root.dump(2); } else { std::cerr << "[save] Could not open " << path << " for writing" << std::endl; }*/ saveJsonToFile(root, path); } void Game::loadGame(int slot) { std::string path = "save_slot" + std::to_string(slot) + ".json"; std::string content = FRG::readSavedTextFile(path); if (content.empty()) { std::cerr << "[save] Save file not found or empty: " << path << std::endl; return; } nlohmann::json root; try { root = nlohmann::json::parse(content); } catch (const std::exception& e) { std::cerr << "[save] Failed to parse save file " << path << ": " << e.what() << std::endl; return; } if (root.value("persistenceVersion", -1) != kSinglePlayerPersistenceVersion || !root.contains("networkState") || root["networkState"].value("schemaVersion", -1) != GameState::kSchemaVersion) { std::cerr << "[save] Unsupported save schema in " << path << std::endl; return; } const auto& networkState = root["networkState"]; const std::string campaignId = networkState.value("campaignId", std::string()); const std::string locationId = networkState.value("currentLocationId", std::string()); const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId); if (!campaign || !campaign->findLocation(locationId)) { std::cerr << "[save] Unknown campaign/location in " << path << std::endl; return; } enterLocationThen(campaignId, locationId, [this, root]() { gameServerHost.load(root); playMusicForLoadedGame(); menuManager.enterGameplay(true); }); } void Game::playMusicForLoadedGame() { const auto state = stateSnapshot(); if (!state) return; if (state->isDarklands) { audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; } else if (state->isNight) { audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg"); menuManager.lastMusicTrack = "music/bishkek night.ogg"; } else { if (state->currentLocationId == "location_dorm") { audioPlayer->crossFadeMusicAsync("music/obshaga.ogg"); menuManager.lastMusicTrack = "music/obshaga.ogg"; } else { audioPlayer->crossFadeMusicAsync("music/bishkek univer day.ogg"); menuManager.lastMusicTrack = "music/bishkek univer day.ogg"; } } } } // namespace