Major refactoring - internal server state machine implemented

This commit is contained in:
Vladislav Khorev committed 2026-10-29 00:09:16 +06:00
1 parent 71c654e5b6
commit 66e93d46f4
48 files changed
+3948 -3696

No files matched your search

+149 -84
View File
@@ -77,15 +77,18 @@ namespace FRG
Location* Game::currentLocation() const {
if (gameState.currentLocationName.empty()) return nullptr;
auto it = gameState.locations.find(gameState.currentLocationName);
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
const auto& locations = internalGameServer.runtimeLocations();
auto it = locations.find(gameState.currentLocationName);
return (it != locations.end()) ? it->second.get() : nullptr;
}
Game::Game()
: newTickCount(0)
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, internalGameServer()
, gameState(internalGameServer.hostState())
, menuManager(renderer, internalGameServer, *audioPlayer)
, newTickCount(0)
, lastTickCount(0)
, audioPlayer(std::make_unique<AudioPlayerAsync>())
, menuManager(renderer, gameState, *audioPlayer)
{
}
@@ -391,15 +394,23 @@ namespace FRG
menuManager.skipCutsceneFunc = [this]() {
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
};
menuManager.callTaxiFunc = [this]() {
if (currentLocation()) currentLocation()->onTaxiCalled();
};
});
loadSteps.push([this]() {
try {
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
menuManager.setup(CONST_ZIP_FILE);
gameState.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestNew, quest->definition.title);
};
gameState.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestCompleted, quest->definition.title);
};
gameState.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestFailed, quest->definition.title);
};
logger() << "UI loaded successfully" << std::endl;
}
catch (const std::exception& e) {
@@ -515,7 +526,8 @@ namespace FRG
}
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
return player && loadedCampaignId == campaignId && gameState.locations.count(locationId) > 0;
return player && loadedCampaignId == campaignId
&& internalGameServer.runtimeLocations().count(locationId) > 0;
}
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
@@ -556,20 +568,24 @@ namespace FRG
void Game::loadCampaign(const std::string& campaignId) {
loadedCampaignId = campaignId;
gameState.campaignId = campaignId;
gameState.storedLocationStates.clear();
gameState.locations.clear();
gameState.players.clear();
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
// The player stays loaded for the whole campaign; locations come and go around him.
player = Location::createPlayer(renderer, audioPlayer.get());
auto& playerState = gameState.players["player"];
if (!playerState) playerState = std::make_shared<CharacterState>();
player = Location::createPlayer(renderer, audioPlayer.get(), *playerState, internalGameServer);
if (player)
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
}
void Game::unloadCampaign() {
unloadLocation(false);
player.reset();
gameState.players.clear();
loadedCampaignId.clear();
gameState.storedLocationStates.clear();
gameState.locations.clear();
renderer.textureManager.UnloadUnused(); // the player's textures
}
@@ -580,23 +596,24 @@ namespace FRG
<< locationId << "'" << std::endl;
return;
}
const bool initializeLocation =
!gameState.locations[locationId].hasPersistentState;
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).
auto stored = gameState.storedLocationStates.find(locationId);
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
gameState.locations[locationId] = loc;
internalGameServer.bindRuntimeLocation(locationId, loc);
}
void Game::unloadLocation(bool storeState) {
for (auto& [name, loc] : gameState.locations) {
if (!storeState) continue;
nlohmann::json locJson;
loc->save(locJson);
gameState.storedLocationStates[name] = std::move(locJson);
if (storeState) internalGameServer.synchronizeRuntimeAuxiliaryState();
else {
for (const auto& [name, loc] : internalGameServer.runtimeLocations())
gameState.locations.erase(name);
}
gameState.locations.clear();
internalGameServer.runtimeLocations().clear();
// Textures only the location's models and characters used are freed now.
renderer.textureManager.UnloadUnused();
}
@@ -619,11 +636,12 @@ namespace FRG
Location* loc = currentLocation();
if (!loc) return;
if (loc->player) {
loc->player->state.position = destPos;
loc->player->setTarget(destPos);
loc->player->stopInPlace();
loc->player->state.facingAngle = destRotY;
loc->player->state.targetFacingAngle = destRotY;
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
workingState.position = destPos;
workingState.facingAngle = destRotY;
workingState.targetFacingAngle = destRotY;
});
loc->stopCharacter(*loc->player);
}
loc->state.cameraAzimuth = destRotY;
loc->state.isDarklands = gameState.isDarklands;
@@ -632,14 +650,16 @@ namespace FRG
loc->scriptEngine.callLocationEnterCallback();
//Reset taxi
loc->state.taxiCar = loc->state.taxiCarInitialState;
loc->state.taxiArrivedCallbackCalled = false;
auto locationState = gameState.locations.find(destName);
if (locationState != gameState.locations.end())
locationState->second.resetTaxi();
gameState.taxiIsCalled = false;
menuManager.onLocationChanged(destName);
});
}
void Game::performFullReset() {
gameState.currentLocationName.clear();
gameState.isDarklands = false;
@@ -666,9 +686,9 @@ namespace FRG
// 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.
gameState.storedLocationStates.clear();
gameState.locations.clear();
std::vector<std::string> loadedIds;
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loadedIds.push_back(name);
unloadLocation(false);
for (const std::string& id : loadedIds) loadLocation(id);
@@ -698,15 +718,8 @@ namespace FRG
if (!content.empty()) {
try {
nlohmann::json root = nlohmann::json::parse(content);
const std::string campaignId = gameState.campaignId;
// GameState::load() merges into existing maps rather than replacing them,
// so any Lua globals set during play that aren't in the pristine snapshot
// must be cleared explicitly before loading it.
gameState.globalInts.clear();
gameState.globalFloats.clear();
gameState.load(root); // applies to the loaded location in place and stores
internalGameServer.load(root); // applies to the loaded location in place and stores
// the other locations' states for when they load.
gameState.campaignId = campaignId; // the snapshot may predate campaigns
return;
} catch (const std::exception& e) {
logger() << "[reset] Failed to parse " << path << ": " << e.what()
@@ -729,7 +742,7 @@ namespace FRG
void Game::saveInitialState() {
nlohmann::json root;
gameState.save(root);
internalGameServer.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);
@@ -747,20 +760,28 @@ namespace FRG
std::shared_ptr<Location> Game::createLocation(const LocationConfig& config)
{
LocationGameState& authoritative = gameState.locations[config.id];
if (!authoritative.location) authoritative.location = std::make_shared<LocationState>();
if (authoritative.location->navigationMaps.empty())
authoritative.location->initNavigation(config.navigationJsonPaths);
LocationSetup params;
params.gameObjectsJsonPath = config.gameObjectsJsonPath;
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
params.npcsJsonPath = config.npcsJsonPath;
params.dialoguesJsonPath = config.dialoguesJsonPath;
params.navigationJsonPaths = config.navigationJsonPaths;
//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;
params.gameState = &authoritative;
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
auto loc = std::make_shared<Location>(
renderer, gameState.inventory, *authoritative.location, internalGameServer, config.id);
loc->setup(params, &gameState.questJournal, audioPlayer.get());
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
@@ -779,8 +800,8 @@ namespace FRG
menuManager.setChatUnread(idx, unread, msg);
};
for (auto& npc : loc->npcs) {
if (npc && npc->state.canAttack) {
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
if (npc && npc->characterState.canAttack) {
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
}
}
loc->requestDarklandsPlayBattleMusic = [this]() {
@@ -792,24 +813,23 @@ namespace FRG
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
};
if (config.lockInteractiveObjectsDuringTutorial) {
// Objects can be picked up once the tutorial reaches "pick up an item".
loc->areInteractiveObjectsLocked = [this]() {
return gameState.tutorialStep < TutorialStep::PickUpItem;
};
}
authoritative.lockInteractiveObjectsDuringTutorial =
config.lockInteractiveObjectsDuringTutorial;
if (config.taxi) {
const LocationConfig::Taxi& taxi = *config.taxi;
loc->state.taxiCarTriggerPosition = taxi.triggerPosition;
loc->state.taxiCarWaypoints = taxi.waypoints;
loc->state.taxiCarInitialState.position = taxi.initialPosition;
loc->state.taxiCarInitialState.rotation = taxi.initialRotation;
loc->state.taxiCarInitialState.mode = NpcCar::Mode::NONE;
loc->state.taxiCarInitialState.velocity = 0.f;
loc->state.taxiCarInitialState.steeringAngle = 0.f;
loc->state.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
loc->state.taxiCar = loc->state.taxiCarInitialState;
LocationState& locationState = *authoritative.location;
locationState.taxiCarTriggerPosition = taxi.triggerPosition;
locationState.taxiCarWaypoints = taxi.waypoints;
locationState.taxiCarInitialState.position = taxi.initialPosition;
locationState.taxiCarInitialState.rotation = taxi.initialRotation;
locationState.taxiCarInitialState.mode = NpcCar::Mode::NONE;
locationState.taxiCarInitialState.velocity = 0.f;
locationState.taxiCarInitialState.steeringAngle = 0.f;
locationState.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
locationState.taxiCar.waypointReachRadius = taxi.waypointReachRadius;
if (!authoritative.hasPersistentState)
authoritative.resetTaxi();
}
return loc;
@@ -866,24 +886,30 @@ namespace FRG
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->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) {
loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) {
menuManager.updateHealthBar(hp, maxHp);
if (attackerIndex != CharacterState::kNoTarget) {
if (!attackerId.empty()) {
//Someone attacked, cancel dialogue and agro the player
if (loc->dialogueSystem.isDialogueActive()) {
loc->dialogueSystem.stopDialogue();
}
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
((loc->player->state.attackTargetIndex >= 0) &&
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
bool currentTargetDead = false;
for (const auto& npc : loc->npcs) {
if (npc && npc->entityId() == loc->player->characterState.attackTargetId) {
currentTargetDead = npc->characterState.hp <= 0.f;
break;
}
}
if ((loc->player->characterState.attackTargetId.empty() || currentTargetDead)
&&
loc->player->isMoving() == false
&&
loc->player->getHp() > 0) {
loc->player->state.attackTargetIndex = attackerIndex;
internalGameServer.submit(SetCharacterTargetCommand{
loc->locationId(), "player", attackerId});
}
}
@@ -905,7 +931,7 @@ namespace FRG
CheckGlError(__FILE__, __LINE__);
logger() << "Shadow map initialized" << std::endl;
for (auto& [name, loc] : gameState.locations) loc->shadowMap = shadowMap;
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap = shadowMap;
}
void Game::destroyShadowMapAndProparateToLocations()
@@ -915,7 +941,7 @@ namespace FRG
shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
for (auto& [name, loc] : gameState.locations) loc->shadowMap.reset();
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
}
@@ -1177,7 +1203,9 @@ namespace FRG
if (loadingCompleted && currentLocation())
{
currentLocation()->update(delta);
// The authoritative implementation advances simulation; a future
// network client can use the same call to poll/apply server updates.
internalGameServer.update(delta);
}
}
@@ -1228,6 +1256,10 @@ namespace FRG
}
void Game::update() {
// Host systems (locations, scripts and loading) still execute in-process.
// Publish any changes they made since the previous frame as a new immutable
// revision before presentation clients query the server.
internalGameServer.markHostMutation();
SDL_Event event;
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
while (SDL_PollEvent(&event)) {
@@ -1332,8 +1364,9 @@ namespace FRG
}
else if (event.button.button == SDL_BUTTON_RIGHT
&& event.type == SDL_MOUSEBUTTONUP
&& editorMode == EditorMode::Navigation
&& currentLocation()) {
&& currentLocation()
&& currentLocation()->editorMode == EditorMode::Navigation
) {
currentLocation()->editor.handleRightClick();
}
}
@@ -1378,15 +1411,32 @@ namespace FRG
}
if (event.key.keysym.sym == SDLK_1)
{
startNightTransition();
currentLocation()->switchNavigation(0);
//startNightTransition();
}
if (event.key.keysym.sym == SDLK_2)
{
startDarklandsTransition();
currentLocation()->switchNavigation(1);
//startDarklandsTransition();
}
if (event.key.keysym.sym == SDLK_3)
{
logger() << "Player position: " << currentLocation()->player->state.position.transpose() << std::endl;
currentLocation()->switchNavigation(2);
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
if (event.key.keysym.sym == SDLK_n)
{
if (currentLocation()->editorMode == EditorMode::None)
{
currentLocation()->editorMode = EditorMode::Navigation;
}
else
{
currentLocation()->editorMode = EditorMode::None;
}
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
}
@@ -1438,12 +1488,18 @@ namespace FRG
if (!currentLocation()) return;
if (currentLocation()->player) {
currentLocation()->player->state.slowMoTimeRemaining = currentLocation()->player->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*currentLocation()->player, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
for (auto& npc : currentLocation()->npcs) {
if (npc) {
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*npc, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
}
}
@@ -1816,10 +1872,11 @@ namespace FRG
if (content.empty()) return {};
try {
nlohmann::json root = nlohmann::json::parse(content);
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {};
SaveSlotInfo info;
info.empty = false;
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
info.locationName = root.value("currentLocationName", "");
info.campaignId = root.at("campaignId").get<std::string>();
info.locationName = root.at("currentLocationId").get<std::string>();
info.savedAt = root.value("savedAt", "");
return info;
} catch (...) {
@@ -1830,7 +1887,7 @@ namespace FRG
void Game::saveGame(int slot)
{
nlohmann::json root;
gameState.save(root);
internalGameServer.save(root);
std::string path = "save_slot" + std::to_string(slot) + ".json";
/*std::ofstream file(path);
@@ -1858,11 +1915,19 @@ namespace FRG
return;
}
// The save's campaign and location may not be loaded yet; the state is applied once they are.
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
const std::string locationId = root.value("currentLocationName", std::string());
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) {
std::cerr << "[save] Unsupported save schema in " << path << std::endl;
return;
}
const std::string campaignId = root.value("campaignId", std::string());
const std::string locationId = root.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]() {
gameState.load(root);
internalGameServer.load(root);
playMusicForLoadedGame();
menuManager.enterGameplay(true);
});