Working on the server
This commit is contained in:
1 parent
075750442f
commit
28c6e83eda
30 files changed
+418
-134
No files matched your search
+22
-13
@@ -8,12 +8,13 @@ This refactor does **not** add multiplayer networking. It establishes the state
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The architecture has four layers:
|
||||
The architecture has five layers:
|
||||
|
||||
1. `GameState` is the aggregate root for persistent, authoritative state.
|
||||
2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots.
|
||||
3. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands.
|
||||
4. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`.
|
||||
3. `ClientLocationState` owns per-client `LocationLocalState` records, including camera orientation. Graphical hosts own this state; it is never replicated.
|
||||
4. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands.
|
||||
5. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`.
|
||||
|
||||
```text
|
||||
Input / UI
|
||||
@@ -69,7 +70,7 @@ lookup and simulation to treat every campaign player by stable ID.
|
||||
|
||||
Each location record owns:
|
||||
|
||||
- One `LocationState`.
|
||||
- One `LocationNetworkState`.
|
||||
- NPC `CharacterState` objects keyed by configured ID.
|
||||
- `InteractiveObjectState` objects keyed by configured ID.
|
||||
- Trigger-zone enabled/inside flags.
|
||||
@@ -193,7 +194,7 @@ Gameplay operations mutate the authoritative `CharacterState`, not the runtime
|
||||
`Character`:
|
||||
|
||||
- `setTarget()` normalizes the destination, avoids redundant requests, stores the
|
||||
optional Lua arrival function name, and asks the authoritative `LocationState` to
|
||||
optional Lua arrival function name, and asks the authoritative `LocationNetworkState` to
|
||||
call `planPathExceptMyself()`.
|
||||
- `forceReplan()` recalculates the current requested destination using the same
|
||||
location navigation state.
|
||||
@@ -210,7 +211,10 @@ collision handling, and presentation-event processing.
|
||||
|
||||
Runtime visual objects do not own their authoritative state objects.
|
||||
|
||||
- `Location` keeps only its `IGameServer` reference and stable location ID. It resolves its immutable `LocationState` view from `IGameServer::snapshot()`.
|
||||
- `Location` keeps its `IGameServer` reference, stable location ID, and a shared
|
||||
`LocationLocalState` supplied by the graphical host. It resolves its immutable
|
||||
`LocationNetworkState` view from `IGameServer::snapshot()`, while rendering and
|
||||
camera input access the local state directly.
|
||||
- `Character` keeps only its `IGameServer` reference plus stable entity and location IDs. It resolves the player-first character view from `IGameServer::snapshot()`.
|
||||
- `InteractiveObject` keeps a private non-owning reference to its `InteractiveObjectState`; its configured object name is its stable ID.
|
||||
|
||||
@@ -224,9 +228,10 @@ and presentation or input work is skipped.
|
||||
The authoritative state objects are owned by `GameState` or `LocationGameState`.
|
||||
The internal server advances them before publishing a new snapshot revision.
|
||||
Runtime input, scripts, and collision handling submit semantic commands that invoke
|
||||
authoritative character and location operations. Location commands cover camera
|
||||
orientation, pending interaction targets, navigation switching and area changes,
|
||||
editor obstacles, and taxi reset. No whole-location commit command is exposed.
|
||||
authoritative character and location operations. Location commands cover pending
|
||||
interaction targets, navigation switching and area changes, editor obstacles, and
|
||||
taxi reset. Camera orientation is deliberately not a server command. No
|
||||
whole-location commit command is exposed.
|
||||
|
||||
Navigation target selection, replanning, stopping, facing, death reset, health
|
||||
assignment, and attack-combo clock changes are implemented by `CharacterState`.
|
||||
@@ -268,7 +273,7 @@ The normal frame flow is:
|
||||
4. Attack impacts are queued, then damage is applied after all characters advance.
|
||||
5. The runtime location consumes value-only events. Navigation requests are sent
|
||||
back as semantic destination commands, whose server handler calls
|
||||
`CharacterState::setTarget()` with the authoritative `LocationState`; arrival
|
||||
`CharacterState::setTarget()` with the authoritative `LocationNetworkState`; arrival
|
||||
function names are resolved through `ScriptEngine`.
|
||||
6. The server invalidates its cached snapshot and advances the revision so runtime
|
||||
presentation can observe the completed simulation tick.
|
||||
@@ -326,8 +331,11 @@ The player state remains campaign-level and survives location changes.
|
||||
|
||||
## Save and Load Schema
|
||||
|
||||
Persistence uses one strict, versioned schema. `schemaVersion` is required and
|
||||
unsupported versions are rejected rather than migrated.
|
||||
Authoritative persistence uses one strict, versioned schema. `schemaVersion` is
|
||||
required and unsupported versions are rejected rather than migrated. A
|
||||
single-player save is a separate versioned envelope containing `networkState`
|
||||
(the authoritative `GameState` JSON) and `localState` (the client-local location
|
||||
map). WebSocket replication sends only the authoritative document.
|
||||
|
||||
- Campaign players are stored once in an ID-keyed `players` object.
|
||||
- Locations are keyed by location ID.
|
||||
@@ -453,7 +461,8 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
|
||||
## Main Files
|
||||
|
||||
- `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence.
|
||||
- `src/LocationGameState.h` / `src/LocationGameState.cpp` — typed location state, simulation, and ID-keyed persistence.
|
||||
- `src/LocationState.h` / `src/LocationState.cpp` — separate local camera and authoritative network location records, plus single-player persistence composition.
|
||||
- `src/LocationGameState.h` / `src/LocationGameState.cpp` — authoritative typed location state, simulation, and ID-keyed persistence.
|
||||
- `src/content/LocationDefinition.h` / `.cpp` — renderer-free content definitions,
|
||||
parsing, and animation metadata loading.
|
||||
- `src/game_server/IGameServer.h` — public snapshot and command contract.
|
||||
|
||||
+25
-11
@@ -35,6 +35,8 @@ not enable the battle-zone test bot.
|
||||
thread from `update()`.
|
||||
- It maintains an internal `InternalGameServer` replica so existing snapshot
|
||||
consumers and transitional runtime-state bindings continue to work.
|
||||
- Camera orientation lives in host-owned `LocationLocalState`, so incoming
|
||||
authoritative snapshots cannot overwrite camera movement.
|
||||
- `submit()` intentionally discards all commands.
|
||||
- `drainEvents()` returns no events.
|
||||
- Client-side save and load calls are ignored because persistence remains
|
||||
@@ -51,11 +53,21 @@ The graphical game uses stable router objects for `IGameServer` and
|
||||
|
||||
When `NETWORK` is defined:
|
||||
|
||||
- `battle_zone` uses `WebsocketGameClient` and `RemoteGameServerHost`.
|
||||
- Every other campaign uses `InternalGameServer` and `LocalGameServerHost`.
|
||||
- `battle_zone_multiplayer` uses `WebsocketGameClient` and `RemoteGameServerHost`.
|
||||
- `main`, `battle_zone`, and every other campaign use `InternalGameServer` and
|
||||
`LocalGameServerHost`.
|
||||
|
||||
The WebSocket client is created only when `battle_zone` is selected and is
|
||||
destroyed when that campaign is unloaded.
|
||||
The WebSocket client is created only when `battle_zone_multiplayer` is selected
|
||||
and is destroyed when that campaign is unloaded. The single-player and
|
||||
multiplayer Battle Zone entries share location content and the start-state path,
|
||||
so the state can be regenerated through the single-player entry. At startup, the
|
||||
standalone server loads the authoritative half of that shared save and rewrites
|
||||
its campaign ID to `battle_zone_multiplayer`; each remote client restores only
|
||||
the local presentation half.
|
||||
|
||||
If DNS resolution, TCP connection, the WebSocket handshake, or the active socket
|
||||
fails, the client unloads the remote campaign and returns to the campaign
|
||||
selection screen.
|
||||
|
||||
## JSON Protocol
|
||||
|
||||
@@ -66,20 +78,22 @@ Messages use a versioned JSON envelope:
|
||||
"protocolVersion": 1,
|
||||
"type": "state",
|
||||
"payload": {
|
||||
"schemaVersion": 1
|
||||
"schemaVersion": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The payload temporarily uses the existing `GameState::save()` representation.
|
||||
The payload uses the authoritative `GameState::save()` representation and excludes
|
||||
the client-local location map, including camera orientation.
|
||||
The client applies it through `InternalGameServer::load()`.
|
||||
|
||||
Malformed JSON, unsupported protocol versions, unsupported message types, and
|
||||
invalid state payloads are logged and ignored.
|
||||
|
||||
This temporary full-state format is expected to be replaced or separated from
|
||||
the persistence schema as command handling and production replication are
|
||||
implemented.
|
||||
Single-player persistence uses a separate envelope that combines this authoritative
|
||||
document with client-local location state. The temporary full-state network format
|
||||
is still expected to be replaced by a dedicated replication schema or deltas as
|
||||
command handling and production replication are implemented.
|
||||
|
||||
## Build Configuration
|
||||
|
||||
@@ -106,7 +120,7 @@ cmake --build server/build --config Debug --target GameServer
|
||||
Run it from the repository root:
|
||||
|
||||
```powershell
|
||||
.\server\build\Debug\GameServer.exe --campaign battle_zone --location uni_exterior
|
||||
.\server\build\Debug\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
|
||||
```
|
||||
|
||||
The listener defaults to `0.0.0.0:8080`. It can be changed with:
|
||||
@@ -116,7 +130,7 @@ The listener defaults to `0.0.0.0:8080`. It can be changed with:
|
||||
--port N
|
||||
```
|
||||
|
||||
Launch the Windows game and select `battle_zone`. The client connects to
|
||||
Launch the Windows game and select `battle_zone_multiplayer`. The client connects to
|
||||
`127.0.0.1:8080`. Selecting another campaign continues to use the local server.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -17,7 +17,20 @@
|
||||
},
|
||||
{
|
||||
"id": "battle_zone",
|
||||
"title": { "ru": "Зона боя", "en": "Battle Zone" },
|
||||
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" },
|
||||
"locations": [
|
||||
"resources/config/locations/battle_zone_uni_exterior.json"
|
||||
],
|
||||
"startLocation": "uni_exterior",
|
||||
"startMusic": "music/bishkek univer day.ogg",
|
||||
"startState": "resources/config/start_state_battle_zone.json",
|
||||
"quests": "resources/config/quests_battle_zone.json",
|
||||
"tutorial": false,
|
||||
"loadingScreen": "resources/w/ui/screen_loading.json"
|
||||
},
|
||||
{
|
||||
"id": "battle_zone_multiplayer",
|
||||
"title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" },
|
||||
"locations": [
|
||||
"resources/config/locations/battle_zone_uni_exterior.json"
|
||||
],
|
||||
|
||||
File renamed without changes.
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"localState": {
|
||||
"locations": {
|
||||
"uni_exterior": {
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkState": {
|
||||
"campaignId": "battle_zone",
|
||||
"chatHistory": [
|
||||
[],
|
||||
@@ -117,8 +126,6 @@
|
||||
"scriptGlobals": {},
|
||||
"state": {
|
||||
"activeNavigationIndex": 0,
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002,
|
||||
"targetInteractNpcId": "",
|
||||
"targetInteractiveObjectId": "",
|
||||
"targetTeleportZoneId": "",
|
||||
@@ -232,12 +239,14 @@
|
||||
"questJournal": {
|
||||
"quests": []
|
||||
},
|
||||
"savedAt": "2026-10-30 14:50",
|
||||
"schemaVersion": 1,
|
||||
"savedAt": "2026-11-05 17:43",
|
||||
"schemaVersion": 2,
|
||||
"taxiIsCalled": false,
|
||||
"tutorialJournalScreenOpened": false,
|
||||
"tutorialNeedOpenTaxiScreen": false,
|
||||
"tutorialPhoneChatScreenOpened": false,
|
||||
"tutorialStep": 0,
|
||||
"tutorialTaxiHintDone": false
|
||||
},
|
||||
"persistenceVersion": 1
|
||||
}
|
||||
+3
-3
@@ -42,11 +42,11 @@ real-time delay.
|
||||
Start the first multiplayer transport iteration:
|
||||
|
||||
```powershell
|
||||
.\server\build\Release\GameServer.exe --campaign battle_zone --location uni_exterior
|
||||
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
|
||||
```
|
||||
|
||||
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
|
||||
afterward and select `battle_zone`; that campaign connects to
|
||||
afterward and select `battle_zone_multiplayer`; that campaign connects to
|
||||
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
|
||||
The server broadcasts an initial full state and then one every five seconds.
|
||||
Client commands are intentionally discarded in this iteration.
|
||||
@@ -83,4 +83,4 @@ CTest covers:
|
||||
- repeated `LocationGameState::setup()` preserving mutable runtime state;
|
||||
- ticking a prepared location with zero players;
|
||||
- idle server startup;
|
||||
- headless loading of `battle_zone/uni_exterior`.
|
||||
- headless loading of `battle_zone_multiplayer/uni_exterior`.
|
||||
+29
-1
@@ -3,6 +3,8 @@
|
||||
#include "game_server/WebsocketGameServer.h"
|
||||
#endif
|
||||
#include "Campaign.h"
|
||||
#include "LocationState.h"
|
||||
#include "utils/Utils.h"
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
@@ -88,7 +90,7 @@ int main(int argc, char** argv)
|
||||
}
|
||||
|
||||
//Temporary override:
|
||||
campaignId = "battle_zone";
|
||||
campaignId = "battle_zone_multiplayer";
|
||||
locationId = "uni_exterior";
|
||||
|
||||
std::signal(SIGINT, requestStop);
|
||||
@@ -98,6 +100,7 @@ int main(int argc, char** argv)
|
||||
if (!campaignId.empty()) {
|
||||
try {
|
||||
FRG::CampaignRegistry::instance().loadFromJson(campaignsPath);
|
||||
std::cout << "[server] Loading...\n" << std::flush;
|
||||
const FRG::CampaignConfig* campaign =
|
||||
FRG::CampaignRegistry::instance().find(campaignId);
|
||||
if (!campaign)
|
||||
@@ -113,7 +116,32 @@ int main(int argc, char** argv)
|
||||
"' has no location '" + selectedLocation + "'");
|
||||
server.startCampaign(*campaign, std::string(), true, false);
|
||||
server.prepareLocation(*location);
|
||||
const std::string startState =
|
||||
FRG::readTextFile(campaign->startStatePath);
|
||||
if (!startState.empty()) {
|
||||
try {
|
||||
nlohmann::json persisted =
|
||||
nlohmann::json::parse(startState);
|
||||
nlohmann::json networkState;
|
||||
FRG::ClientLocationState ignoredLocalState;
|
||||
FRG::loadSinglePlayerPersistence(
|
||||
persisted, networkState, ignoredLocalState);
|
||||
networkState["campaignId"] = campaignId;
|
||||
networkState["currentLocationId"] = selectedLocation;
|
||||
server.load(networkState);
|
||||
std::cout << "Loaded initial state '"
|
||||
<< campaign->startStatePath << "'\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Ignoring invalid initial state '"
|
||||
<< campaign->startStatePath << "': "
|
||||
<< error.what() << '\n';
|
||||
}
|
||||
}
|
||||
server.activateLocation(selectedLocation);
|
||||
if (!campaign->tutorial) {
|
||||
server.submit(FRG::SetTutorialStepCommand{
|
||||
FRG::TutorialStep::Completed});
|
||||
}
|
||||
std::cout << "Loaded campaign '" << campaignId
|
||||
<< "', location '" << selectedLocation
|
||||
<< "' with shared player '" << server.localPlayerId() << "'\n";
|
||||
|
||||
@@ -16,10 +16,69 @@ int fail(const char* message)
|
||||
|
||||
int main()
|
||||
{
|
||||
FRG::ClientLocationState localState;
|
||||
auto camera = localState.getOrCreate("test_location");
|
||||
camera->cameraAzimuth = 0.75f;
|
||||
camera->cameraInclination = 1.25f;
|
||||
nlohmann::json localJson;
|
||||
localState.save(localJson);
|
||||
FRG::ClientLocationState restoredLocalState;
|
||||
restoredLocalState.load(localJson);
|
||||
const auto restoredCamera =
|
||||
restoredLocalState.getOrCreate("test_location");
|
||||
if (restoredCamera->cameraAzimuth != 0.75f ||
|
||||
restoredCamera->cameraInclination != 1.25f) {
|
||||
return fail("Local camera state did not round trip");
|
||||
}
|
||||
|
||||
FRG::LocationNetworkState networkLocationState;
|
||||
nlohmann::json networkLocationJson;
|
||||
networkLocationState.save(networkLocationJson);
|
||||
if (networkLocationJson.contains("cameraAzimuth") ||
|
||||
networkLocationJson.contains("cameraInclination")) {
|
||||
return fail("Network location state contains local camera fields");
|
||||
}
|
||||
|
||||
const nlohmann::json authoritativeJson{
|
||||
{"schemaVersion", FRG::GameState::kSchemaVersion},
|
||||
{"campaignId", "test"}
|
||||
};
|
||||
nlohmann::json persistenceJson;
|
||||
FRG::saveSinglePlayerPersistence(
|
||||
authoritativeJson, localState, persistenceJson);
|
||||
nlohmann::json restoredAuthoritativeJson;
|
||||
FRG::ClientLocationState restoredPersistenceLocalState;
|
||||
FRG::loadSinglePlayerPersistence(
|
||||
persistenceJson, restoredAuthoritativeJson,
|
||||
restoredPersistenceLocalState);
|
||||
if (restoredAuthoritativeJson != authoritativeJson ||
|
||||
restoredPersistenceLocalState.getOrCreate("test_location")
|
||||
->cameraAzimuth != 0.75f) {
|
||||
return fail("Composed single-player state did not round trip");
|
||||
}
|
||||
if (persistenceJson.at("networkState").contains("localState")) {
|
||||
return fail("Local state leaked into authoritative state");
|
||||
}
|
||||
|
||||
const std::filesystem::path repositoryRoot =
|
||||
std::filesystem::path(__FILE__).parent_path().parent_path().parent_path();
|
||||
std::filesystem::current_path(repositoryRoot);
|
||||
|
||||
FRG::CampaignRegistry::instance().loadFromJson(
|
||||
"resources/config/campaigns.json");
|
||||
const FRG::CampaignConfig* singlePlayerBattleZone =
|
||||
FRG::CampaignRegistry::instance().find("battle_zone");
|
||||
const FRG::CampaignConfig* multiplayerBattleZoneConfig =
|
||||
FRG::CampaignRegistry::instance().find("battle_zone_multiplayer");
|
||||
if (FRG::CampaignRegistry::instance().all().size() != 3 ||
|
||||
!singlePlayerBattleZone || !multiplayerBattleZoneConfig) {
|
||||
return fail("Expected three campaigns including both Battle Zone modes");
|
||||
}
|
||||
if (singlePlayerBattleZone->startStatePath !=
|
||||
multiplayerBattleZoneConfig->startStatePath) {
|
||||
return fail("Battle Zone modes do not share their initial state");
|
||||
}
|
||||
|
||||
FRG::LocationDefinition definition;
|
||||
definition.config.id = "test_location";
|
||||
|
||||
@@ -87,8 +146,12 @@ int main()
|
||||
}
|
||||
|
||||
FRG::InternalGameServer networkServer;
|
||||
networkServer.startCampaign(battleZone, {}, true, false);
|
||||
FRG::CampaignConfig multiplayerBattleZone = battleZone;
|
||||
multiplayerBattleZone.id = "battle_zone_multiplayer";
|
||||
networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
|
||||
const auto networkSnapshot = networkServer.snapshot();
|
||||
if (networkSnapshot->campaignId != "battle_zone_multiplayer")
|
||||
return fail("Network campaign ID was not retained");
|
||||
if (networkSnapshot->players.count("player") != 1)
|
||||
return fail("Network campaign shared player was not created");
|
||||
if (networkSnapshot->players.count("battle_zone_test_bot") != 0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "game_server/NetworkProtocol.h"
|
||||
#include "LocationState.h"
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
@@ -16,10 +17,14 @@ void require(bool condition, const char* message)
|
||||
|
||||
int main()
|
||||
{
|
||||
FRG::LocationNetworkState locationState;
|
||||
nlohmann::json savedLocationState;
|
||||
locationState.save(savedLocationState);
|
||||
const nlohmann::json original{
|
||||
{"schemaVersion", 1},
|
||||
{"campaignId", "battle_zone"},
|
||||
{"players", {{"player", {{"hp", 200.0}}}}}
|
||||
{"schemaVersion", 2},
|
||||
{"campaignId", "battle_zone_multiplayer"},
|
||||
{"players", {{"player", {{"hp", 200.0}}}}},
|
||||
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
|
||||
};
|
||||
const std::string message = FRG::makeStateMessage(original);
|
||||
nlohmann::json parsed;
|
||||
@@ -28,6 +33,10 @@ int main()
|
||||
FRG::parseStateMessage(message, parsed, error),
|
||||
"valid state envelope was rejected");
|
||||
require(parsed == original, "state payload did not round trip");
|
||||
require(
|
||||
!parsed["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
|
||||
!parsed["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
|
||||
"local camera state leaked into the network payload");
|
||||
|
||||
require(
|
||||
!FRG::parseStateMessage(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "external/nlohmann/json.hpp"
|
||||
#include <iostream>
|
||||
|
||||
|
||||
namespace FRG {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
@@ -47,7 +47,7 @@ void CharacterState::stopInPlace() {
|
||||
void CharacterState::setTarget(
|
||||
const Eigen::Vector3f& target,
|
||||
std::string callbackName,
|
||||
const LocationState& location,
|
||||
const LocationNetworkState& location,
|
||||
const std::string& entityId)
|
||||
{
|
||||
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
|
||||
@@ -79,7 +79,7 @@ void CharacterState::setTarget(
|
||||
}
|
||||
|
||||
void CharacterState::forceReplan(
|
||||
const LocationState& location, const std::string& entityId)
|
||||
const LocationNetworkState& location, const std::string& entityId)
|
||||
{
|
||||
const Eigen::Vector3f normalizedTarget(
|
||||
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace FRG {
|
||||
|
||||
struct CharacterUpdateContext;
|
||||
struct CharacterUpdateEvents;
|
||||
struct LocationState;
|
||||
struct LocationNetworkState;
|
||||
|
||||
enum class AnimationState {
|
||||
STAND = 0,
|
||||
@@ -142,9 +142,9 @@ public:
|
||||
void stopInPlace();
|
||||
void setTarget(const Eigen::Vector3f& target,
|
||||
std::string callbackName,
|
||||
const LocationState& location,
|
||||
const LocationNetworkState& location,
|
||||
const std::string& entityId);
|
||||
void forceReplan(const LocationState& location,
|
||||
void forceReplan(const LocationNetworkState& location,
|
||||
const std::string& entityId);
|
||||
void stopAndRotateToTarget(std::string characterId);
|
||||
void resetPlayerAfterDeath();
|
||||
|
||||
+39
-23
@@ -552,7 +552,7 @@ namespace FRG
|
||||
CampaignRegistry::instance().find(campaignId);
|
||||
if (!campaign) return;
|
||||
#if defined(NETWORK)
|
||||
remoteCampaignActive = campaignId == "battle_zone";
|
||||
remoteCampaignActive = campaignId == "battle_zone_multiplayer";
|
||||
if (remoteCampaignActive) {
|
||||
websocketGameClient = std::make_unique<WebsocketGameClient>();
|
||||
remoteGameServerHost =
|
||||
@@ -644,12 +644,7 @@ namespace FRG
|
||||
});
|
||||
loc->stopCharacter(*loc->player);
|
||||
}
|
||||
auto locationState = loc->stateSnapshot();
|
||||
const float inclination = locationState
|
||||
? locationState->cameraInclination
|
||||
: 1.1036f;
|
||||
server().submit(SetLocationCameraCommand{
|
||||
destName, destRotY, inclination});
|
||||
loc->localState().cameraAzimuth = destRotY;
|
||||
loc->scriptEngine.callLocationEnterCallback();
|
||||
|
||||
//Reset taxi
|
||||
@@ -1163,6 +1158,17 @@ namespace FRG
|
||||
// The authoritative implementation advances simulation; a future
|
||||
// network client can use the same call to poll/apply server updates.
|
||||
gameServerHost.update(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();
|
||||
}
|
||||
|
||||
@@ -1524,11 +1530,9 @@ namespace FRG
|
||||
currentLocation()->lastMouseY = eventY;
|
||||
|
||||
// Snapshot current angles so we can measure how far the user rotates.
|
||||
auto state = currentLocation()->stateSnapshot();
|
||||
if (state) {
|
||||
dragStartAzimuth = state->cameraAzimuth;
|
||||
dragStartInclination = state->cameraInclination;
|
||||
}
|
||||
const LocationLocalState& state = currentLocation()->localState();
|
||||
dragStartAzimuth = state.cameraAzimuth;
|
||||
dragStartInclination = state.cameraInclination;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1736,10 +1740,9 @@ namespace FRG
|
||||
if (cameraDragging
|
||||
&& game
|
||||
&& game->tutorialStep == TutorialStep::RotateCamera) {
|
||||
auto state = currentLocation()->stateSnapshot();
|
||||
if (!state) return;
|
||||
float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth);
|
||||
float deltaInc = std::abs(state->cameraInclination - dragStartInclination);
|
||||
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});
|
||||
@@ -1894,12 +1897,18 @@ namespace FRG
|
||||
if (content.empty()) return {};
|
||||
try {
|
||||
nlohmann::json root = nlohmann::json::parse(content);
|
||||
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {};
|
||||
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 = root.at("campaignId").get<std::string>();
|
||||
info.locationName = root.at("currentLocationId").get<std::string>();
|
||||
info.savedAt = root.value("savedAt", "");
|
||||
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 {};
|
||||
@@ -1937,12 +1946,19 @@ namespace FRG
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) {
|
||||
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 std::string campaignId = root.value("campaignId", std::string());
|
||||
const std::string locationId = root.value("currentLocationId", std::string());
|
||||
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;
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ struct StoredChatMessage {
|
||||
};
|
||||
|
||||
struct GameState : public ISaveable {
|
||||
static constexpr int kSchemaVersion = 1;
|
||||
static constexpr int kSchemaVersion = 2;
|
||||
|
||||
// --- World ---
|
||||
// Campaign being played (see CampaignRegistry).
|
||||
|
||||
+21
-21
@@ -1,4 +1,4 @@
|
||||
#include "Location.h"
|
||||
#include "Location.h"
|
||||
#include "utils/Utils.h"
|
||||
#include "render/OpenGlExtensions.h"
|
||||
#include <iostream>
|
||||
@@ -62,13 +62,13 @@ namespace FRG
|
||||
{
|
||||
}
|
||||
|
||||
std::shared_ptr<const LocationState> Location::stateSnapshot() const
|
||||
std::shared_ptr<const LocationNetworkState> Location::stateSnapshot() const
|
||||
{
|
||||
auto snapshot = gameServer_.snapshot();
|
||||
if (!snapshot) return {};
|
||||
auto location = snapshot->locations.find(locationId_);
|
||||
if (location == snapshot->locations.end()) return {};
|
||||
return std::shared_ptr<const LocationState>(
|
||||
return std::shared_ptr<const LocationNetworkState>(
|
||||
std::move(snapshot), &location->second.state);
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ namespace FRG
|
||||
|
||||
// ---- Pending-target lookup ----
|
||||
|
||||
TeleportZone* Location::getTargetTeleportZone(const LocationState& state)
|
||||
TeleportZone* Location::getTargetTeleportZone(const LocationNetworkState& state)
|
||||
{
|
||||
if (state.targetTeleportZoneId.empty()) return nullptr;
|
||||
for (auto& zone : teleportZones)
|
||||
@@ -321,6 +321,8 @@ namespace FRG
|
||||
{
|
||||
if (!params.gameState) throw std::runtime_error("Location requires authoritative state binding");
|
||||
if (!params.definition) throw std::runtime_error("Location requires prepared content definition");
|
||||
if (!params.localState) throw std::runtime_error("Location requires local state binding");
|
||||
localState_ = params.localState;
|
||||
const LocationDefinition& definition = *params.definition;
|
||||
this->audioPlayer = audioPlayer;
|
||||
pathVisualization.initialize();
|
||||
@@ -1006,8 +1008,8 @@ namespace FRG
|
||||
renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom });
|
||||
//renderer.TranslateMatrix({ 0, -6.f, 0 });
|
||||
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
const Eigen::Vector3f camTarget =
|
||||
playerState ? playerState->position : Eigen::Vector3f::Zero();
|
||||
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
|
||||
@@ -1200,8 +1202,8 @@ namespace FRG
|
||||
renderer.LoadIdentity();
|
||||
renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom });
|
||||
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
auto playerState = player ? player->stateSnapshot() : nullptr;
|
||||
const Eigen::Vector3f camTarget =
|
||||
playerState ? playerState->position : Eigen::Vector3f::Zero();
|
||||
@@ -1340,8 +1342,8 @@ namespace FRG
|
||||
renderer.LoadIdentity();
|
||||
renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom });
|
||||
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
const Eigen::Vector3f camTarget =
|
||||
playerState ? playerState->position : Eigen::Vector3f::Zero();
|
||||
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
|
||||
@@ -1586,8 +1588,8 @@ namespace FRG
|
||||
renderer.LoadIdentity();
|
||||
renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom });
|
||||
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
|
||||
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
|
||||
const Eigen::Vector3f camTarget =
|
||||
playerState ? playerState->position : Eigen::Vector3f::Zero();
|
||||
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
|
||||
@@ -1963,8 +1965,8 @@ namespace FRG
|
||||
float aspect = (float)Environment::width / (float)Environment::height;
|
||||
float tanHalfFov = tan(CAMERA_FOV_Y * 0.5f);
|
||||
|
||||
float cosAzim = cos(locationState->cameraAzimuth), sinAzim = sin(locationState->cameraAzimuth);
|
||||
float cosIncl = cos(locationState->cameraInclination), sinIncl = sin(locationState->cameraInclination);
|
||||
float cosAzim = cos(localState_->cameraAzimuth), sinAzim = sin(localState_->cameraAzimuth);
|
||||
float cosIncl = cos(localState_->cameraInclination), sinIncl = sin(localState_->cameraInclination);
|
||||
|
||||
Eigen::Vector3f camRight(cosAzim, 0.f, sinAzim);
|
||||
Eigen::Vector3f camForward(sinAzim * cosIncl, -sinIncl, -cosAzim * cosIncl);
|
||||
@@ -2168,16 +2170,14 @@ namespace FRG
|
||||
lastMouseY = eventY;
|
||||
|
||||
const float sensitivity = 0.005f;
|
||||
auto state = stateSnapshot();
|
||||
if (!state) return;
|
||||
const float azimuth = state->cameraAzimuth + dx * sensitivity;
|
||||
float inclination = state->cameraInclination + dy * sensitivity;
|
||||
localState_->cameraAzimuth += dx * sensitivity;
|
||||
float inclination =
|
||||
localState_->cameraInclination + dy * sensitivity;
|
||||
|
||||
const float minInclination = M_PI * 30.f / 180.f;
|
||||
const float maxInclination = M_PI * 0.5f;
|
||||
inclination = max(minInclination, min(maxInclination, inclination));
|
||||
gameServer_.submit(SetLocationCameraCommand{
|
||||
locationId_, azimuth, inclination});
|
||||
localState_->cameraInclination =
|
||||
max(minInclination, min(maxInclination, inclination));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -1,4 +1,4 @@
|
||||
#pragma once
|
||||
#pragma once
|
||||
|
||||
#include "render/Renderer.h"
|
||||
#include "Environment.h"
|
||||
@@ -48,7 +48,7 @@ namespace FRG
|
||||
float radius = 1.5f;
|
||||
float hysteresis = 0.3f; // exit fires at radius + hysteresis to prevent flickering
|
||||
bool enabled = true;
|
||||
bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationState)
|
||||
bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationNetworkState)
|
||||
};
|
||||
|
||||
struct LocationSetup
|
||||
@@ -68,6 +68,7 @@ namespace FRG
|
||||
// setup() creates a private one when this is null.
|
||||
std::shared_ptr<Character> player;
|
||||
LocationGameState* gameState = nullptr;
|
||||
std::shared_ptr<LocationLocalState> localState;
|
||||
};
|
||||
|
||||
class Location
|
||||
@@ -78,7 +79,9 @@ namespace FRG
|
||||
// Detaches the (shared, longer-lived) player from this location.
|
||||
~Location();
|
||||
const std::string& locationId() const { return locationId_; }
|
||||
std::shared_ptr<const LocationState> stateSnapshot() const;
|
||||
std::shared_ptr<const LocationNetworkState> stateSnapshot() const;
|
||||
LocationLocalState& localState() { return *localState_; }
|
||||
const LocationLocalState& localState() const { return *localState_; }
|
||||
bool isDarklands() const;
|
||||
bool isNight() const;
|
||||
bool isDawn() const;
|
||||
@@ -234,13 +237,14 @@ namespace FRG
|
||||
private:
|
||||
IGameServer& gameServer_;
|
||||
std::string locationId_;
|
||||
std::shared_ptr<LocationLocalState> localState_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Character>> remotePlayers_;
|
||||
void synchronizeRemotePlayers();
|
||||
void drawRemotePlayerNames(
|
||||
const Eigen::Matrix4f& cameraViewMatrix,
|
||||
const Eigen::Matrix4f& projectionMatrix);
|
||||
// ---- Pointer-to-index helpers ----
|
||||
TeleportZone* getTargetTeleportZone(const LocationState& state);
|
||||
TeleportZone* getTargetTeleportZone(const LocationNetworkState& state);
|
||||
|
||||
enum class InstancedTexture { None, Color, Darklands };
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ bool LocationGameState::setup(
|
||||
LocationDefinition content, const std::string& zipFile)
|
||||
{
|
||||
const bool initializeRuntimeFields = !hasPersistentState;
|
||||
if (!location) location = std::make_shared<LocationState>();
|
||||
if (!location) location = std::make_shared<LocationNetworkState>();
|
||||
if (location->navigationMaps.empty())
|
||||
location->initNavigation(content.config.navigationJsonPaths, zipFile);
|
||||
lockInteractiveObjectsDuringTutorial =
|
||||
@@ -377,7 +377,7 @@ void LocationGameState::save(nlohmann::json& out) const
|
||||
void LocationGameState::load(const nlohmann::json& in)
|
||||
{
|
||||
hasPersistentState = true;
|
||||
if (!location) location = std::make_shared<LocationState>();
|
||||
if (!location) location = std::make_shared<LocationNetworkState>();
|
||||
location->load(in.at("state"));
|
||||
|
||||
const auto& npcValues = in.at("npcs");
|
||||
|
||||
@@ -49,7 +49,7 @@ struct LocationSimulationEvents {
|
||||
// simulation objects stable addresses; runtime renderers and Location pointers
|
||||
// are deliberately absent, and public clients receive value-only snapshots.
|
||||
struct LocationGameState {
|
||||
std::shared_ptr<LocationState> location = std::make_shared<LocationState>();
|
||||
std::shared_ptr<LocationNetworkState> location = std::make_shared<LocationNetworkState>();
|
||||
std::unordered_map<std::string, std::shared_ptr<CharacterState>> npcs;
|
||||
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>> interactiveObjects;
|
||||
bool lockInteractiveObjectsDuringTutorial = false;
|
||||
|
||||
+82
-12
@@ -1,7 +1,85 @@
|
||||
#include "LocationState.h"
|
||||
#include <stdexcept>
|
||||
|
||||
namespace FRG {
|
||||
|
||||
std::shared_ptr<LocationLocalState> ClientLocationState::getOrCreate(
|
||||
const std::string& locationId)
|
||||
{
|
||||
auto& state = locations[locationId];
|
||||
if (!state) state = std::make_shared<LocationLocalState>();
|
||||
return state;
|
||||
}
|
||||
|
||||
void ClientLocationState::clear()
|
||||
{
|
||||
locations.clear();
|
||||
}
|
||||
|
||||
void LocationLocalState::save(nlohmann::json& out) const
|
||||
{
|
||||
out = {
|
||||
{"cameraAzimuth", cameraAzimuth},
|
||||
{"cameraInclination", cameraInclination}
|
||||
};
|
||||
}
|
||||
|
||||
void LocationLocalState::load(const nlohmann::json& in)
|
||||
{
|
||||
cameraAzimuth = in.at("cameraAzimuth").get<float>();
|
||||
cameraInclination = in.at("cameraInclination").get<float>();
|
||||
}
|
||||
|
||||
void ClientLocationState::save(nlohmann::json& out) const
|
||||
{
|
||||
out["locations"] = nlohmann::json::object();
|
||||
for (const auto& [id, state] : locations) {
|
||||
if (!state) continue;
|
||||
nlohmann::json value;
|
||||
state->save(value);
|
||||
out["locations"][id] = std::move(value);
|
||||
}
|
||||
}
|
||||
|
||||
void ClientLocationState::load(const nlohmann::json& in)
|
||||
{
|
||||
const auto& values = in.at("locations");
|
||||
if (!values.is_object())
|
||||
throw std::runtime_error("Client location state locations must be an object");
|
||||
for (auto& [id, state] : locations)
|
||||
if (state) *state = LocationLocalState{};
|
||||
for (const auto& [id, value] : values.items())
|
||||
getOrCreate(id)->load(value);
|
||||
}
|
||||
|
||||
void saveSinglePlayerPersistence(
|
||||
const nlohmann::json& networkState,
|
||||
const ClientLocationState& localState,
|
||||
nlohmann::json& out)
|
||||
{
|
||||
nlohmann::json savedLocalState;
|
||||
localState.save(savedLocalState);
|
||||
out = {
|
||||
{"persistenceVersion", kSinglePlayerPersistenceVersion},
|
||||
{"networkState", networkState},
|
||||
{"localState", std::move(savedLocalState)}
|
||||
};
|
||||
}
|
||||
|
||||
void loadSinglePlayerPersistence(
|
||||
const nlohmann::json& in,
|
||||
nlohmann::json& networkState,
|
||||
ClientLocationState& localState)
|
||||
{
|
||||
const int version = in.value("persistenceVersion", -1);
|
||||
if (version != kSinglePlayerPersistenceVersion)
|
||||
throw std::runtime_error(
|
||||
"Unsupported single-player persistence version: " +
|
||||
std::to_string(version));
|
||||
networkState = in.at("networkState");
|
||||
localState.load(in.at("localState"));
|
||||
}
|
||||
|
||||
namespace {
|
||||
nlohmann::json saveCar(const NpcCar& car)
|
||||
{
|
||||
@@ -44,12 +122,12 @@ namespace FRG {
|
||||
}
|
||||
|
||||
|
||||
std::vector<Eigen::Vector3f> LocationState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const
|
||||
std::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const
|
||||
{
|
||||
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {});
|
||||
}
|
||||
|
||||
void LocationState::initNavigation(
|
||||
void LocationNetworkState::initNavigation(
|
||||
const std::vector<std::string>& paths, const std::string& zipFile)
|
||||
{
|
||||
const int savedActiveNavigationIndex = activeNavigationIndex;
|
||||
@@ -68,11 +146,8 @@ namespace FRG {
|
||||
: 0;
|
||||
}
|
||||
|
||||
void LocationState::save(nlohmann::json& out) const
|
||||
void LocationNetworkState::save(nlohmann::json& out) const
|
||||
{
|
||||
out["cameraAzimuth"] = cameraAzimuth;
|
||||
out["cameraInclination"] = cameraInclination;
|
||||
|
||||
out["activeNavigationIndex"] = activeNavigationIndex;
|
||||
|
||||
out["targetInteractiveObjectId"] = targetInteractiveObjectId;
|
||||
@@ -85,13 +160,8 @@ void LocationState::save(nlohmann::json& out) const
|
||||
};
|
||||
}
|
||||
|
||||
void LocationState::load(const nlohmann::json& in)
|
||||
void LocationNetworkState::load(const nlohmann::json& in)
|
||||
{
|
||||
//logger() << "LocationState::load step 1" << std::endl;
|
||||
|
||||
cameraAzimuth = in.value("cameraAzimuth", cameraAzimuth);
|
||||
cameraInclination = in.value("cameraInclination", cameraInclination);
|
||||
|
||||
activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex);
|
||||
|
||||
targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string());
|
||||
|
||||
+31
-5
@@ -3,11 +3,41 @@
|
||||
#include "ISaveable.h"
|
||||
#include "NpcCar.h"
|
||||
#include "navigation/PathFinder.h"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace FRG {
|
||||
|
||||
struct LocationState : public ISaveable {
|
||||
inline constexpr int kSinglePlayerPersistenceVersion = 1;
|
||||
|
||||
struct LocationLocalState : public ISaveable {
|
||||
float cameraAzimuth = -2.35f;
|
||||
float cameraInclination = 1.1036f;
|
||||
|
||||
void save(nlohmann::json& out) const override;
|
||||
void load(const nlohmann::json& in) override;
|
||||
};
|
||||
|
||||
struct ClientLocationState : public ISaveable {
|
||||
std::unordered_map<std::string, std::shared_ptr<LocationLocalState>> locations;
|
||||
|
||||
std::shared_ptr<LocationLocalState> getOrCreate(const std::string& locationId);
|
||||
void clear();
|
||||
void save(nlohmann::json& out) const override;
|
||||
void load(const nlohmann::json& in) override;
|
||||
};
|
||||
|
||||
void saveSinglePlayerPersistence(
|
||||
const nlohmann::json& networkState,
|
||||
const ClientLocationState& localState,
|
||||
nlohmann::json& out);
|
||||
void loadSinglePlayerPersistence(
|
||||
const nlohmann::json& in,
|
||||
nlohmann::json& networkState,
|
||||
ClientLocationState& localState);
|
||||
|
||||
struct LocationNetworkState : public ISaveable {
|
||||
//This value is populated from the JSON configs and should not be changed during the regular gameplay.
|
||||
//It might be changed only in the editor mode during development.
|
||||
std::vector<PathFinder> navigationMaps;
|
||||
@@ -16,10 +46,6 @@ struct LocationState : public ISaveable {
|
||||
const std::vector<std::string>& paths,
|
||||
const std::string& zipFile = std::string());
|
||||
|
||||
// ---- Camera ----
|
||||
float cameraAzimuth = -2.35f;
|
||||
float cameraInclination = 1.1036f;
|
||||
|
||||
// ---- Navigation ----
|
||||
int activeNavigationIndex = 0;
|
||||
|
||||
|
||||
+2
-2
@@ -57,6 +57,8 @@ namespace FRG {
|
||||
|
||||
void showMainMenu();
|
||||
void showMenuAfterGameCompleted();
|
||||
// Returns to the main menu with the campaign list already open.
|
||||
void showCampaignScreen();
|
||||
bool isMainMenuOpen() const { return uiState_ == GameUiState::MainMenu; }
|
||||
|
||||
void openPhoneScreen();
|
||||
@@ -223,8 +225,6 @@ namespace FRG {
|
||||
void showLanguageScreen();
|
||||
void showLoadGameScreen();
|
||||
void showSaveGameScreen();
|
||||
// Campaign list opened by "Start game".
|
||||
void showCampaignScreen();
|
||||
// Text of a load/save slot button.
|
||||
std::string slotLabel(const SaveSlotInfo& info);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ struct InteractiveObjectSnapshot {
|
||||
|
||||
struct LocationSnapshot {
|
||||
std::string id;
|
||||
LocationState state;
|
||||
LocationNetworkState state;
|
||||
std::unordered_map<std::string, CharacterSnapshot> npcs;
|
||||
std::unordered_map<std::string, InteractiveObjectSnapshot> interactiveObjects;
|
||||
std::unordered_map<std::string, TriggerZoneGameState> triggerZones;
|
||||
@@ -95,11 +95,6 @@ struct AppendChatMessageCommand {
|
||||
struct SetTaxiCalledCommand { bool called; };
|
||||
struct StartTaxiCommand { std::string locationId; };
|
||||
struct ResetTaxiCommand { std::string locationId; };
|
||||
struct SetLocationCameraCommand {
|
||||
std::string locationId;
|
||||
float azimuth;
|
||||
float inclination;
|
||||
};
|
||||
struct SetLocationTargetsCommand {
|
||||
std::string locationId;
|
||||
std::string interactiveObjectId;
|
||||
@@ -232,7 +227,6 @@ using GameCommand = std::variant<
|
||||
SetTaxiCalledCommand,
|
||||
StartTaxiCommand,
|
||||
ResetTaxiCommand,
|
||||
SetLocationCameraCommand,
|
||||
SetLocationTargetsCommand,
|
||||
SwitchLocationNavigationCommand,
|
||||
SetNavigationAreaAvailableCommand,
|
||||
|
||||
@@ -305,12 +305,6 @@ void InternalGameServer::submit(const GameCommand& command)
|
||||
auto location = state_.locations.find(value.locationId);
|
||||
if (location != state_.locations.end())
|
||||
location->second.resetTaxi();
|
||||
} else if constexpr (std::is_same_v<T, SetLocationCameraCommand>) {
|
||||
auto location = state_.locations.find(value.locationId);
|
||||
if (location != state_.locations.end() && location->second.location) {
|
||||
location->second.location->cameraAzimuth = value.azimuth;
|
||||
location->second.location->cameraInclination = value.inclination;
|
||||
}
|
||||
} else if constexpr (std::is_same_v<T, SetLocationTargetsCommand>) {
|
||||
auto location = state_.locations.find(value.locationId);
|
||||
if (location != state_.locations.end() && location->second.location) {
|
||||
@@ -326,7 +320,7 @@ void InternalGameServer::submit(const GameCommand& command)
|
||||
value.index >= 0 &&
|
||||
value.index < static_cast<int>(
|
||||
location->second.location->navigationMaps.size())) {
|
||||
LocationState& locationState = *location->second.location;
|
||||
LocationNetworkState& locationState = *location->second.location;
|
||||
locationState.activeNavigationIndex = value.index;
|
||||
for (const auto& [id, player] : state_.players)
|
||||
if (player) player->forceReplan(locationState, id);
|
||||
@@ -336,7 +330,7 @@ void InternalGameServer::submit(const GameCommand& command)
|
||||
} else if constexpr (std::is_same_v<T, SetNavigationAreaAvailableCommand>) {
|
||||
auto location = state_.locations.find(value.locationId);
|
||||
if (location != state_.locations.end() && location->second.location) {
|
||||
LocationState& locationState = *location->second.location;
|
||||
LocationNetworkState& locationState = *location->second.location;
|
||||
const int index = locationState.activeNavigationIndex;
|
||||
if (index >= 0 &&
|
||||
index < static_cast<int>(locationState.navigationMaps.size())) {
|
||||
@@ -347,7 +341,7 @@ void InternalGameServer::submit(const GameCommand& command)
|
||||
} else if constexpr (std::is_same_v<T, AddNavigationObstacleCommand>) {
|
||||
auto location = state_.locations.find(value.locationId);
|
||||
if (location != state_.locations.end() && location->second.location) {
|
||||
LocationState& locationState = *location->second.location;
|
||||
LocationNetworkState& locationState = *location->second.location;
|
||||
const int index = locationState.activeNavigationIndex;
|
||||
if (index >= 0 &&
|
||||
index < static_cast<int>(locationState.navigationMaps.size())) {
|
||||
|
||||
@@ -15,18 +15,21 @@ void LocalGameServerHost::initializeCampaign(
|
||||
const std::string& campaignId, const std::string& questsPath,
|
||||
const std::string& zipFile)
|
||||
{
|
||||
localState_.clear();
|
||||
server_.initializeCampaign(campaignId, questsPath, zipFile);
|
||||
}
|
||||
|
||||
void LocalGameServerHost::clearCampaign()
|
||||
{
|
||||
runtimeLocations_.clear();
|
||||
localState_.clear();
|
||||
server_.clearCampaign();
|
||||
}
|
||||
|
||||
void LocalGameServerHost::resetCampaign(
|
||||
const std::string& questsPath, const std::string& zipFile)
|
||||
{
|
||||
localState_.clear();
|
||||
server_.resetCampaign(questsPath, zipFile);
|
||||
}
|
||||
|
||||
@@ -44,6 +47,7 @@ void LocalGameServerHost::setupRuntimeLocation(
|
||||
throw std::runtime_error("Location state was not prepared");
|
||||
params.definition = stored->second.definition;
|
||||
params.gameState = &stored->second;
|
||||
params.localState = localState_.getOrCreate(id);
|
||||
location->setup(params, audioPlayer);
|
||||
server_.publishMutation();
|
||||
}
|
||||
@@ -122,12 +126,20 @@ void LocalGameServerHost::update(std::int64_t deltaMs)
|
||||
void LocalGameServerHost::save(nlohmann::json& out)
|
||||
{
|
||||
synchronizeRuntimeAuxiliaryState();
|
||||
server_.save(out);
|
||||
nlohmann::json networkState;
|
||||
server_.save(networkState);
|
||||
saveSinglePlayerPersistence(networkState, localState_, out);
|
||||
}
|
||||
|
||||
void LocalGameServerHost::load(const nlohmann::json& in)
|
||||
{
|
||||
server_.load(in);
|
||||
nlohmann::json networkState;
|
||||
ClientLocationState loadedLocalState;
|
||||
loadSinglePlayerPersistence(in, networkState, loadedLocalState);
|
||||
server_.load(networkState);
|
||||
nlohmann::json localStateJson;
|
||||
loadedLocalState.save(localStateJson);
|
||||
localState_.load(localStateJson);
|
||||
applyStateToRuntime();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "IGameServerHost.h"
|
||||
#include "../LocationState.h"
|
||||
#include <unordered_map>
|
||||
|
||||
namespace FRG {
|
||||
@@ -39,6 +40,7 @@ private:
|
||||
void applyStateToRuntime();
|
||||
|
||||
InternalGameServer& server_;
|
||||
ClientLocationState localState_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "NetworkProtocol.h"
|
||||
#include <exception>
|
||||
|
||||
|
||||
namespace FRG {
|
||||
|
||||
std::string makeStateMessage(const nlohmann::json& state)
|
||||
|
||||
@@ -15,6 +15,7 @@ void RemoteGameServerHost::initializeCampaign(
|
||||
const std::string& campaignId, const std::string&,
|
||||
const std::string& zipFile)
|
||||
{
|
||||
localState_.clear();
|
||||
const CampaignConfig* campaign =
|
||||
CampaignRegistry::instance().find(campaignId);
|
||||
if (!campaign)
|
||||
@@ -25,6 +26,7 @@ void RemoteGameServerHost::initializeCampaign(
|
||||
void RemoteGameServerHost::clearCampaign()
|
||||
{
|
||||
runtimeLocations_.clear();
|
||||
localState_.clear();
|
||||
client_.replica_.clearCampaign();
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ void RemoteGameServerHost::setupRuntimeLocation(
|
||||
throw std::runtime_error("Remote location state was not prepared");
|
||||
params.definition = stored->second.definition;
|
||||
params.gameState = &stored->second;
|
||||
params.localState = localState_.getOrCreate(id);
|
||||
location->setup(params, audioPlayer);
|
||||
}
|
||||
|
||||
@@ -101,9 +104,16 @@ void RemoteGameServerHost::save(nlohmann::json&)
|
||||
// Persistence remains server-side.
|
||||
}
|
||||
|
||||
void RemoteGameServerHost::load(const nlohmann::json&)
|
||||
void RemoteGameServerHost::load(const nlohmann::json& in)
|
||||
{
|
||||
// Persistence remains server-side.
|
||||
// The authoritative half is loaded by the standalone server. Each client
|
||||
// restores only its local presentation state from the shared start state.
|
||||
nlohmann::json ignoredNetworkState;
|
||||
ClientLocationState loadedLocalState;
|
||||
loadSinglePlayerPersistence(in, ignoredNetworkState, loadedLocalState);
|
||||
nlohmann::json localStateJson;
|
||||
loadedLocalState.save(localStateJson);
|
||||
localState_.load(localStateJson);
|
||||
}
|
||||
|
||||
} // namespace FRG
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "IGameServerHost.h"
|
||||
#include "../LocationState.h"
|
||||
#include <unordered_map>
|
||||
|
||||
namespace FRG {
|
||||
@@ -33,6 +34,7 @@ public:
|
||||
|
||||
private:
|
||||
WebsocketGameClient& client_;
|
||||
ClientLocationState localState_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public:
|
||||
}
|
||||
|
||||
bool connected() const { return connected_.load(); }
|
||||
bool connectionFailed() const { return connectionFailed_.load(); }
|
||||
|
||||
private:
|
||||
void resolve()
|
||||
@@ -101,6 +102,7 @@ private:
|
||||
void fail(const char* operation, beast::error_code error)
|
||||
{
|
||||
connected_.store(false);
|
||||
connectionFailed_.store(true);
|
||||
std::cerr << "[network] WebSocket " << operation << " failed: "
|
||||
<< error.message() << '\n';
|
||||
}
|
||||
@@ -114,6 +116,7 @@ private:
|
||||
std::string port_;
|
||||
std::string path_;
|
||||
std::atomic_bool connected_{false};
|
||||
std::atomic_bool connectionFailed_{false};
|
||||
std::mutex messagesMutex_;
|
||||
std::vector<std::string> messages_;
|
||||
};
|
||||
@@ -186,4 +189,9 @@ bool WebsocketGameClient::connected() const
|
||||
return impl_->connected();
|
||||
}
|
||||
|
||||
bool WebsocketGameClient::connectionFailed() const
|
||||
{
|
||||
return impl_->connectionFailed();
|
||||
}
|
||||
|
||||
} // namespace FRG
|
||||
@@ -29,6 +29,7 @@ public:
|
||||
void load(const nlohmann::json& in) override;
|
||||
|
||||
bool connected() const;
|
||||
bool connectionFailed() const;
|
||||
|
||||
private:
|
||||
friend class RemoteGameServerHost;
|
||||
|
||||
Reference in new issue
Block a user