Files
shadow-over-bishkek001/src/Game.cpp
T

2033 lines
78 KiB
C++

#include "Game.h"
#include "game_server/NetworkProtocol.h"
#include "AnimatedModel.h"
#include "Localization.h"
#include "utils/Utils.h"
#include <fstream>
#include "items/ItemRegistry.h"
#include "Campaign.h"
#include "render/OpenGlExtensions.h"
#include <iostream>
#include "render/TextureManager.h"
#include "TextModel.h"
#include <random>
#include <cmath>
#include <algorithm>
#include <functional>
#include <type_traits>
#ifdef __ANDROID__
#include <android/log.h>
#endif
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#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<AudioPlayerAsync>())
, 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<std::function<void()>> 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<void()> 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<std::function<void()>> 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<WebsocketGameClient>(endpoint);
remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*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<Location> 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<std::string> 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<char> 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<char> 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<Location> 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<Location>(
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<ShadowMap>(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::milliseconds>(
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<int64_t>(CONST_MAX_TIME_INTERVAL));
lastTickCount = now;
if (delta > 0) {
updateDarklandsFlash(delta);
menuManager.uiManager.update(static_cast<float>(delta));
menuManager.topUiManager.update(static_cast<float>(delta));
menuManager.loadingUiManager.update(static_cast<float>(delta));
if (!menuManager.isMainMenuOpen())
menuManager.update(static_cast<float>(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<decltype(value)>;
if constexpr (std::is_same_v<T, InventoryItemAddedEvent>) {
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<T, InventoryItemRemovedEvent>) {
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<T, Quest::QuestEvent>) {
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<int>(event.tfinger.x * Environment::width);
int eventY = static_cast<int>(event.tfinger.y * Environment::height);
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
int64_t fingerId = static_cast<int64_t>(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<int>((float)eventX / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((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<int>((float)eventX / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((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<float>(a.eventX - b.eventX);
float dy = static_cast<float>(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<float>(itA->second.eventX - itB->second.eventX);
float dy = static_cast<float>(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<float>(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<float>(Environment::projectionWidth);
const float H = static_cast<float>(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<std::string>();
info.locationName =
networkState.at("currentLocationId").get<std::string>();
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