2033 lines
78 KiB
C++
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
|