Separating state out of the location

This commit is contained in:
Vladislav Khorev committed 2026-10-30 14:56:24 +06:00
1 parent 5e9a663812
commit 297bd7377f
45 files changed
+2114 -778

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
#include "external/nlohmann/json.hpp"
#include <iostream>
namespace FRG {
using json = nlohmann::json;
-4
View File
@@ -65,8 +65,6 @@ void Character::loadAnimation(AnimationState animState, const std::string& filen
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
void Character::loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile) {
@@ -77,8 +75,6 @@ void Character::loadBinaryAnimation(AnimationState animState, const std::string&
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
std::unique_ptr<Character> Character::createFromState(const CharacterState& st, IGameServer& server,
-1
View File
@@ -114,7 +114,6 @@ private:
friend class ScriptEngine;
friend class LocationEditor;
friend class PathVisualization;
friend class InternalGameServer;
IGameServer& gameServer_;
std::string locationId_;
std::string entityId_;
+22 -19
View File
@@ -79,12 +79,13 @@ namespace FRG
Location* Game::currentLocation() const {
const auto state = stateSnapshot();
if (!state || state->currentLocationId.empty()) return nullptr;
return internalGameServer.runtimeLocation(state->currentLocationId);
return gameServerHost.runtimeLocation(state->currentLocationId);
}
Game::Game()
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, internalGameServer()
, gameServerHost(internalGameServer)
, menuManager(renderer, internalGameServer, *audioPlayer)
, newTickCount(0)
, lastTickCount(0)
@@ -505,7 +506,7 @@ namespace FRG
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
return player && loadedCampaignId == campaignId
&& internalGameServer.hasRuntimeLocation(locationId);
&& gameServerHost.hasRuntimeLocation(locationId);
}
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
@@ -548,7 +549,7 @@ namespace FRG
const CampaignConfig* campaign =
CampaignRegistry::instance().find(campaignId);
if (!campaign) return;
internalGameServer.initializeCampaign(
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(
@@ -562,7 +563,7 @@ namespace FRG
unloadLocation(false);
player.reset();
loadedCampaignId.clear();
internalGameServer.clearCampaign();
gameServerHost.clearCampaign();
renderer.textureManager.UnloadUnused(); // the player's textures
}
@@ -575,18 +576,19 @@ namespace FRG
<< locationId << "'" << std::endl;
return;
}
const bool initializeLocation = internalGameServer.prepareLocation(*config);
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).
internalGameServer.bindRuntimeLocation(locationId, loc);
gameServerHost.bindRuntimeLocation(locationId, loc);
}
void Game::unloadLocation(bool storeState) {
internalGameServer.clearRuntimeLocations(storeState);
gameServerHost.clearRuntimeLocations(storeState);
// Textures only the location's models and characters used are freed now.
renderer.textureManager.UnloadUnused();
}
@@ -639,8 +641,8 @@ 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.
std::vector<std::string> loadedIds =
internalGameServer.loadedRuntimeLocationIds();
internalGameServer.resetCampaign(
gameServerHost.loadedRuntimeLocationIds();
gameServerHost.resetCampaign(
activeCampaign().questsPath, CONST_ZIP_FILE);
unloadLocation(false);
for (const std::string& id : loadedIds) loadLocation(id);
@@ -649,6 +651,7 @@ namespace FRG
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
// Re-comment before shipping.
//saveInitialState();
}
void Game::performResetToInitialState() {
@@ -671,7 +674,7 @@ namespace FRG
if (!content.empty()) {
try {
nlohmann::json root = nlohmann::json::parse(content);
internalGameServer.load(root); // applies to the loaded location in place and stores
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) {
@@ -696,7 +699,7 @@ namespace FRG
void Game::saveInitialState() {
nlohmann::json root;
internalGameServer.save(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);
@@ -729,7 +732,7 @@ namespace FRG
auto loc = std::make_shared<Location>(
renderer, internalGameServer, config.id);
internalGameServer.setupRuntimeLocation(
gameServerHost.setupRuntimeLocation(
config.id, loc, std::move(params), audioPlayer.get());
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
const auto state = stateSnapshot();
@@ -861,8 +864,8 @@ namespace FRG
CheckGlError(__FILE__, __LINE__);
logger() << "Shadow map initialized" << std::endl;
for (const std::string& id : internalGameServer.loadedRuntimeLocationIds())
if (Location* loc = internalGameServer.runtimeLocation(id))
for (const std::string& id : gameServerHost.loadedRuntimeLocationIds())
if (Location* loc = gameServerHost.runtimeLocation(id))
loc->shadowMap = shadowMap;
}
@@ -873,8 +876,8 @@ namespace FRG
shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
for (const std::string& id : internalGameServer.loadedRuntimeLocationIds())
if (Location* loc = internalGameServer.runtimeLocation(id))
for (const std::string& id : gameServerHost.loadedRuntimeLocationIds())
if (Location* loc = gameServerHost.runtimeLocation(id))
loc->shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
@@ -1135,7 +1138,7 @@ namespace FRG
{
// The authoritative implementation advances simulation; a future
// network client can use the same call to poll/apply server updates.
internalGameServer.update(delta);
gameServerHost.update(delta);
processServerEvents();
}
@@ -1882,7 +1885,7 @@ namespace FRG
void Game::saveGame(int slot)
{
nlohmann::json root;
internalGameServer.save(root);
gameServerHost.save(root);
std::string path = "save_slot" + std::to_string(slot) + ".json";
/*std::ofstream file(path);
@@ -1922,7 +1925,7 @@ namespace FRG
return;
}
enterLocationThen(campaignId, locationId, [this, root]() {
internalGameServer.load(root);
gameServerHost.load(root);
playMusicForLoadedGame();
menuManager.enterGameplay(true);
});
+2
View File
@@ -22,6 +22,7 @@
#include "Location.h"
#include "AudioPlayerAsync.h"
#include "game_server/InternalGameServer.h"
#include "game_server/LocalGameServerHost.h"
namespace FRG {
struct LocationConfig;
@@ -83,6 +84,7 @@ namespace FRG {
private:
InternalGameServer internalGameServer;
LocalGameServerHost gameServerHost;
public:
IGameServer& server() { return internalGameServer; }
+6 -3
View File
@@ -1,4 +1,5 @@
#include "GameState.h"
#include "utils/Utils.h"
#include <ctime>
#include <iomanip>
#include <sstream>
@@ -209,8 +210,8 @@ void GameState::load(const nlohmann::json& in)
// --- Players ---
const auto& playerValues = in.at("players");
if (!playerValues.is_object() || !playerValues.contains("player"))
throw std::runtime_error("Game state requires players.player");
if (!playerValues.is_object())
throw std::runtime_error("Game state players must be an object");
for (const auto& [id, value] : playerValues.items()) {
auto& player = players[id];
if (!player) player = std::make_shared<CharacterState>();
@@ -224,7 +225,9 @@ void GameState::load(const nlohmann::json& in)
// --- Locations ---
const auto& locationValues = in.at("locations");
if (!locationValues.is_object() ||
if (!locationValues.is_object())
throw std::runtime_error("Game state locations must be an object");
if (!currentLocationName.empty() &&
!locationValues.contains(currentLocationName))
throw std::runtime_error(
"Game state does not contain current location '" +
+63 -13
View File
@@ -320,6 +320,8 @@ namespace FRG
void Location::setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer)
{
if (!params.gameState) throw std::runtime_error("Location requires authoritative state binding");
if (!params.definition) throw std::runtime_error("Location requires prepared content definition");
const LocationDefinition& definition = *params.definition;
this->audioPlayer = audioPlayer;
pathVisualization.initialize();
@@ -330,16 +332,18 @@ namespace FRG
// Load static game objects
{
StaticObjectSet staticObjects = GameObjectLoader::loadAndCreateGameObjects(params.gameObjectsJsonPath, renderer, CONST_ZIP_FILE);
StaticObjectSet staticObjects =
GameObjectLoader::createGameObjects(
definition.staticObjects, renderer, CONST_ZIP_FILE);
gameObjects = std::move(staticObjects.single);
instancedBatches = std::move(staticObjects.batches);
}
SDL_PumpEvents();
// Load interactive objects
interactiveObjects = GameObjectLoader::loadAndCreateInteractiveObjects(
params.interactiveObjectsJsonPath, renderer, params.gameState->interactiveObjects,
CONST_ZIP_FILE);
interactiveObjects = GameObjectLoader::createInteractiveObjects(
definition.interactiveObjects, renderer,
params.gameState->interactiveObjects, CONST_ZIP_FILE);
SDL_PumpEvents();
auto sparkTexture = renderer.textureManager.LoadFromPng("resources/w/spark.png", CONST_ZIP_FILE);
@@ -347,10 +351,6 @@ namespace FRG
if (!params.player) throw std::runtime_error("Location requires game-level player binding");
player = params.player;
player->bindToLocation(locationId_);
updateCharacterState(*player, [&params](CharacterState& workingState) {
workingState.position = params.playerPosition;
});
setCharacterTarget(*player, params.playerPosition);
synchronizeRemotePlayers();
logger() << "Load resurces step 9" << std::endl;
SDL_PumpEvents();
@@ -358,8 +358,9 @@ namespace FRG
// Load NPCs from JSON. Aggressive (canAttack) NPCs need a player target
// and hit sparks; peaceful NPCs don't take damage from the player so
// they don't need either.
npcs = GameObjectLoader::loadAndCreate_Npcs(
params.npcsJsonPath, renderer, params.gameState->npcs, gameServer_, locationId_, CONST_ZIP_FILE);
npcs = GameObjectLoader::createNpcs(
definition.npcs, renderer, params.gameState->npcs,
gameServer_, locationId_, CONST_ZIP_FILE);
for (auto& npc : npcs) {
if (!npc) continue;
auto state = npc->stateSnapshot();
@@ -371,9 +372,58 @@ namespace FRG
SDL_PumpEvents();
loadTeleportZones(params.teleportsJsonPath, CONST_ZIP_FILE);
loadTriggerZones(params.triggerZonesJsonPath, CONST_ZIP_FILE);
loadPointLights(params.lightsJsonPath, CONST_ZIP_FILE);
auto activeTeleportTexture =
renderer.textureManager.LoadFromPng(
"resources/w/star.png", CONST_ZIP_FILE);
auto inactiveTeleportTexture =
renderer.textureManager.LoadFromPng(
"resources/w/star_red.png", CONST_ZIP_FILE);
for (const TeleportDefinition& data : definition.teleports) {
TeleportZone zone;
zone.id = data.id;
zone.position = data.position;
zone.radius = data.radius;
zone.destinationLocation = data.destinationLocation;
zone.destinationPosition = data.destinationPosition;
zone.destinationRotationY = data.destinationRotationY;
zone.automatic = data.automatic;
zone.automaticRadius = data.automaticRadius;
auto active = params.gameState->teleportZones.find(data.id);
zone.active = active != params.gameState->teleportZones.end()
? active->second : data.active;
zone.initSparks(
activeTeleportTexture, inactiveTeleportTexture);
teleportZones.push_back(std::move(zone));
}
for (const TriggerZoneDefinition& data : definition.triggerZones) {
TriggerZone zone;
zone.id = data.id;
zone.position = data.position;
zone.radius = data.radius;
zone.hysteresis = data.hysteresis;
auto state = params.gameState->triggerZones.find(data.id);
if (state != params.gameState->triggerZones.end()) {
zone.enabled = state->second.enabled;
zone.playerInside = state->second.playerInside;
} else {
zone.enabled = data.enabled;
}
triggerZones.push_back(std::move(zone));
}
for (const PointLightDefinition& data : definition.pointLights) {
PointLight light;
light.id = data.id;
light.position = data.position;
light.direction = data.direction;
light.color = data.color;
light.autoLight = data.autoLight;
light.autoLightDistance = data.autoLightDistance;
light.limitX = data.limitX;
light.limitY = data.limitY;
light.autoLightLimitX = data.autoLightLimitX;
light.autoLightLimitY = data.autoLightLimitY;
pointLights.push_back(std::move(light));
}
SDL_PumpEvents();
+2 -1
View File
@@ -53,6 +53,7 @@ namespace FRG
struct LocationSetup
{
std::shared_ptr<const LocationDefinition> definition;
std::string gameObjectsJsonPath;
std::string interactiveObjectsJsonPath;
std::string npcsJsonPath;
@@ -225,7 +226,7 @@ namespace FRG
friend class LocationEditor;
friend class Game;
friend class ScriptEngine;
friend class InternalGameServer;
friend class LocalGameServerHost;
Renderer& renderer;
AudioPlayerAsync* audioPlayer;
+94
View File
@@ -10,6 +10,100 @@ constexpr float PI = 3.14159265358979323846f;
constexpr float NPC_TALK_DISTANCE = 1.35f;
}
bool LocationGameState::setup(
LocationDefinition content, const std::string& zipFile)
{
const bool initializeRuntimeFields = !hasPersistentState;
if (!location) location = std::make_shared<LocationState>();
if (location->navigationMaps.empty())
location->initNavigation(content.config.navigationJsonPaths, zipFile);
lockInteractiveObjectsDuringTutorial =
content.config.lockInteractiveObjectsDuringTutorial;
if (content.config.taxi) {
const LocationConfig::Taxi& taxi = *content.config.taxi;
location->taxiCarTriggerPosition = taxi.triggerPosition;
location->taxiCarWaypoints = taxi.waypoints;
location->taxiCarInitialState.position = taxi.initialPosition;
location->taxiCarInitialState.rotation = taxi.initialRotation;
location->taxiCarInitialState.mode = NpcCar::Mode::NONE;
location->taxiCarInitialState.velocity = 0.f;
location->taxiCarInitialState.steeringAngle = 0.f;
location->taxiCarInitialState.waypointReachRadius =
taxi.waypointReachRadius;
location->taxiCar.waypointReachRadius = taxi.waypointReachRadius;
if (initializeRuntimeFields) resetTaxi();
}
std::unordered_map<std::string, bool> configuredNpcs;
for (const NpcData& data : content.npcs) {
configuredNpcs.emplace(data.id, true);
CharacterState defaults = data.toCharacterState();
loadCharacterAnimationMetadata(defaults, zipFile);
auto& state = npcs[data.id];
if (!state) {
state = std::make_shared<CharacterState>(std::move(defaults));
} else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(defaults);
state->load(runtime);
}
}
for (const auto& [id, state] : npcs)
if (configuredNpcs.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown NPC '" + id + "'");
std::unordered_map<std::string, bool> configuredObjects;
for (const InteractiveObjectData& data : content.interactiveObjects) {
configuredObjects.emplace(data.id, true);
InteractiveObjectState defaults = data.toInteractiveObjectState();
auto& state = interactiveObjects[data.id];
if (!state) {
state =
std::make_shared<InteractiveObjectState>(std::move(defaults));
} else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(defaults);
state->load(runtime);
}
}
for (const auto& [id, state] : interactiveObjects)
if (configuredObjects.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown interactive object '" +
id + "'");
std::unordered_map<std::string, TriggerZoneGameState> newTriggers;
for (const TriggerZoneDefinition& data : content.triggerZones) {
auto existing = triggerZones.find(data.id);
newTriggers[data.id] = existing != triggerZones.end()
? existing->second
: TriggerZoneGameState{data.enabled, false};
}
if (hasPersistentState && newTriggers.size() != triggerZones.size())
throw std::runtime_error(
"Saved trigger-zone IDs do not match location content");
triggerZones = std::move(newTriggers);
std::unordered_map<std::string, bool> newTeleports;
for (const TeleportDefinition& data : content.teleports) {
auto existing = teleportZones.find(data.id);
newTeleports[data.id] =
existing != teleportZones.end() ? existing->second : data.active;
}
if (hasPersistentState && newTeleports.size() != teleportZones.size())
throw std::runtime_error(
"Saved teleport IDs do not match location content");
teleportZones = std::move(newTeleports);
definition =
std::make_shared<const LocationDefinition>(std::move(content));
return initializeRuntimeFields;
}
CharacterState* LocationGameState::findCharacter(
const std::string& id, PlayerStateMap& players)
{
+3
View File
@@ -3,6 +3,7 @@
#include "CharacterState.h"
#include "LocationState.h"
#include "items/InteractiveObjectState.h"
#include "content/LocationDefinition.h"
#include "external/nlohmann/json.hpp"
#include <string>
#include <unordered_map>
@@ -57,7 +58,9 @@ struct LocationGameState {
std::unordered_map<std::string, bool> teleportZones;
nlohmann::json dialogue = nlohmann::json::object();
nlohmann::json scriptGlobals = nlohmann::json::object();
std::shared_ptr<const LocationDefinition> definition;
bool setup(LocationDefinition content, const std::string& zipFile);
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const;
+3 -7
View File
@@ -2,8 +2,6 @@
namespace FRG {
extern const char* CONST_ZIP_FILE;
namespace {
nlohmann::json saveCar(const NpcCar& car)
{
@@ -51,7 +49,8 @@ namespace FRG {
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {});
}
void LocationState::initNavigation(const std::vector<std::string>& paths)
void LocationState::initNavigation(
const std::vector<std::string>& paths, const std::string& zipFile)
{
const int savedActiveNavigationIndex = activeNavigationIndex;
//navigationMapPaths = paths;
@@ -59,10 +58,7 @@ namespace FRG {
navigationMaps.resize(paths.size());
for (size_t i = 0; i < paths.size(); ++i) {
// Service the OS message queue between builds so the window doesn't
// get flagged as "not responding" while loading many navigation grids.
SDL_PumpEvents();
navigationMaps[i].build(paths[i], CONST_ZIP_FILE);
navigationMaps[i].build(paths[i], zipFile);
}
activeNavigationIndex =
+5 -3
View File
@@ -12,7 +12,9 @@ struct LocationState : public ISaveable {
//It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const;
void initNavigation(const std::vector<std::string>& paths);
void initNavigation(
const std::vector<std::string>& paths,
const std::string& zipFile = std::string());
// ---- Camera ----
float cameraAzimuth = -2.35f;
@@ -29,8 +31,8 @@ struct LocationState : public ISaveable {
NpcCar taxiCarInitialState;
NpcCar taxiCar;
bool taxiArrivedCallbackCalled = false;
Vector3f taxiCarTriggerPosition = Vector3f(0,0,0);
std::vector<Vector3f> taxiCarWaypoints;
Eigen::Vector3f taxiCarTriggerPosition = Eigen::Vector3f(0,0,0);
std::vector<Eigen::Vector3f> taxiCarWaypoints;
// (trigger/teleport zone runtime flags are serialised by id directly in Location::save/load)
+3 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "render/Renderer.h"
#include "Environment.h"
#include <Eigen/Core>
#include <cstddef>
#include <vector>
namespace FRG {
struct NpcCar
+427
View File
@@ -0,0 +1,427 @@
#include "LocationDefinition.h"
#include "../external/nlohmann/json.hpp"
#include "../utils/Utils.h"
#include <Eigen/Geometry>
#include <cmath>
#include <cstring>
#include <stdexcept>
#include <unordered_set>
namespace FRG {
namespace {
using json = nlohmann::json;
constexpr float kPi = 3.14159265358979323846f;
json loadJson(const std::string& path, const std::string& zipFile)
{
if (path.empty()) return json::object();
std::string content;
if (zipFile.empty()) {
content = readTextFile(path);
} else {
const auto bytes = readFileFromZIP(path, zipFile);
content.assign(bytes.begin(), bytes.end());
}
if (content.empty()) throw std::runtime_error("Failed to read " + path);
try {
return json::parse(content);
} catch (const std::exception& error) {
throw std::runtime_error(
"JSON parse error in " + path + ": " + error.what());
}
}
GameObjectData parseGameObject(const json& item)
{
GameObjectData data;
data.name = item.value("name", "Unknown");
data.texturePath = item.value("texturePath", "");
data.textureDarkandsPath = item.value("textureDarkandsPath", "");
data.meshPath = item.value("meshPath", "");
data.rotationX = item.value("rotationX", 0.f);
data.rotationY = item.value("rotationY", 0.f);
data.rotationZ = item.value("rotationZ", 0.f);
data.positionX = item.value("positionX", 0.f);
data.positionY = item.value("positionY", 0.f);
data.positionZ = item.value("positionZ", 0.f);
data.scale = item.value("scale", 1.f);
data.instanced = item.value("instanced", true);
return data;
}
Eigen::Vector3f vector(
const json& item, const char* x, const char* y, const char* z,
const Eigen::Vector3f& fallback = Eigen::Vector3f::Zero())
{
return {item.value(x, fallback.x()), item.value(y, fallback.y()),
item.value(z, fallback.z())};
}
std::pair<int, int> readBinaryAnimationMetadata(
const std::string& path, const std::string& zipFile)
{
const std::vector<char> bytes = zipFile.empty()
? readFile(path) : readFileFromZIP(path, zipFile);
if (bytes.size() < 8)
throw std::runtime_error("Invalid animation file " + path);
const char* cursor = bytes.data();
const char* end = cursor + bytes.size();
auto skip = [&](std::size_t size) {
if (size > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
cursor += size;
};
auto readU32 = [&]() {
std::uint32_t value = 0;
if (sizeof(value) > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
std::memcpy(&value, cursor, sizeof(value));
cursor += sizeof(value);
return value;
};
auto readI32 = [&]() {
std::int32_t value = 0;
if (sizeof(value) > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
std::memcpy(&value, cursor, sizeof(value));
cursor += sizeof(value);
return value;
};
auto skipString = [&]() { skip(readU32()); };
if (std::memcmp(cursor, "BSMF", 4) != 0)
throw std::runtime_error("Invalid animation file " + path);
skip(4);
if (readU32() != 2)
throw std::runtime_error("Unsupported animation version in " + path);
skip(16 * sizeof(float));
const std::uint32_t boneCount = readU32();
for (std::uint32_t bone = 0; bone < boneCount; ++bone) {
skip(3 * sizeof(float) + sizeof(float) + 9 * sizeof(float));
skip(sizeof(std::int32_t));
skip(static_cast<std::size_t>(readU32()) * sizeof(std::int32_t));
}
for (std::uint32_t bone = 0; bone < boneCount; ++bone) skipString();
const std::uint32_t meshCount = readU32();
for (std::uint32_t mesh = 0; mesh < meshCount; ++mesh) {
skipString();
const std::uint32_t vertexCount = readU32();
skip(static_cast<std::size_t>(vertexCount) * 3 * sizeof(float));
const std::uint32_t faceCount = readU32();
skip(static_cast<std::size_t>(faceCount) * 3 * 2 * sizeof(float));
skip(static_cast<std::size_t>(vertexCount) * 3 * sizeof(float));
const std::uint32_t triangleCount = readU32();
skip(static_cast<std::size_t>(triangleCount) * 3 * sizeof(std::int32_t));
for (std::uint32_t vertex = 0; vertex < vertexCount; ++vertex)
skip(static_cast<std::size_t>(readU32()) *
(sizeof(std::int32_t) + sizeof(float)));
}
const std::uint32_t keyframeCount = readU32();
int lastFrame = 0;
for (std::uint32_t frame = 0; frame < keyframeCount; ++frame) {
lastFrame = readI32();
skip(static_cast<std::size_t>(boneCount) *
(3 * sizeof(float) + 16 * sizeof(float)));
}
return {0, keyframeCount == 0 ? 1 : lastFrame + 1};
}
} // namespace
LoadedGameObjectState GameObjectData::toLoadedGameObjectState() const
{
LoadedGameObjectState state;
state.texturePath = texturePath;
state.textureDarkandsPath = textureDarkandsPath;
state.meshPath = meshPath;
state.meshRotationX = rotationX;
state.meshRotationY = rotationY;
state.meshRotationZ = rotationZ;
state.meshScale = scale;
return state;
}
InteractiveObjectState InteractiveObjectData::toInteractiveObjectState() const
{
InteractiveObjectState state;
state.objectInfo = base.toLoadedGameObjectState();
state.interactionRadius = interactionRadius;
state.approachRadius = approachRadius;
state.castShadow = castShadow;
state.castShadowNight = castShadowNight;
state.isActive = isActive;
state.activateFunctionName = activateFunctionName;
state.pivot = pivot;
state.boundsMin = boundsMin;
state.boundsMax = boundsMax;
state.hasInteractionPosition = hasInteractionPosition;
state.interactionPosition = interactionPosition;
state.jsonPositionX = base.positionX;
state.jsonPositionY = base.positionY;
state.jsonPositionZ = base.positionZ;
state.position =
Eigen::Vector3f(base.positionX, base.positionY, base.positionZ);
return state;
}
CharacterState NpcData::toCharacterState() const
{
CharacterState state;
state.npcName = name;
state.position = {positionX, positionY, positionZ};
state.homePosition = {positionX, 0.f, positionZ};
state.stopInPlace();
state.facingAngle = facingAngle;
state.walkSpeed = walkSpeed;
state.rotationSpeed = rotationSpeed;
state.modelScale = modelScale;
state.setHp(hp);
state.initialHp = hp;
state.visibleRange = visibleRange;
state.canAttack = canAttack;
if (canAttack) state.attackTargetId = "player";
state.enabled = enabled;
state.interactionRadius = interactionRadius;
Eigen::Quaternionf correction = Eigen::Quaternionf::Identity();
if (modelCorrectionRotX != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotX, Eigen::Vector3f::UnitX())) * correction;
if (modelCorrectionRotY != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotY, Eigen::Vector3f::UnitY())) * correction;
if (modelCorrectionRotZ != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotZ, Eigen::Vector3f::UnitZ())) * correction;
state.modelCorrectionRotation = correction;
auto addAnimation = [&](AnimationState animation, const std::string& path) {
if (!path.empty())
state.creationInfo.animationPaths.emplace_back(animation, path);
};
addAnimation(AnimationState::STAND, animationIdlePath);
addAnimation(AnimationState::WALK, animationWalkPath);
addAnimation(AnimationState::ACTION_IDLE, animationActionIdlePath);
addAnimation(AnimationState::ACTION_ATTACK, animationActionAttackPath);
addAnimation(AnimationState::STAND_TO_ACTION, animationStandToActionPath);
addAnimation(AnimationState::ACTION_TO_STAND, animationActionToStandPath);
addAnimation(AnimationState::ACTION_TO_DEATH, animationActionToDeathPath);
addAnimation(AnimationState::DEATH_IDLE, animationDeathIdlePath);
state.creationInfo.meshTexturePaths = {
meshTextures.begin(), meshTextures.end()};
state.creationInfo.singleTexturePath = texturePath;
return state;
}
void loadCharacterAnimationMetadata(
CharacterState& state, const std::string& zipFile)
{
for (const auto& [animation, path] : state.creationInfo.animationPaths) {
if (path.size() < 5 || path.substr(path.size() - 5) != ".anim")
continue;
try {
const auto [startFrame, frameCount] =
readBinaryAnimationMetadata(path, zipFile);
state.setAnimationMetadata(animation, startFrame, frameCount);
} catch (const std::exception& error) {
logger() << "[content] Animation metadata failed for " << path
<< ": " << error.what() << '\n';
}
}
}
LocationDefinition LocationContentLoader::load(
const LocationConfig& config, const std::string& zipFile)
{
LocationDefinition result;
result.config = config;
if (!config.gameObjectsJsonPath.empty()) {
const json root = loadJson(config.gameObjectsJsonPath, zipFile);
for (const auto& item : root.value("objects", json::array())) {
GameObjectData data = parseGameObject(item);
if (!data.meshPath.empty())
result.staticObjects.push_back(std::move(data));
}
}
if (!config.interactiveObjectsJsonPath.empty()) {
const json root = loadJson(config.interactiveObjectsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("objects", json::array())) {
InteractiveObjectData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate interactive-object id in " +
config.interactiveObjectsJsonPath);
data.base = parseGameObject(item);
data.interactionRadius = item.value("interactionRadius", 2.f);
data.approachRadius =
item.value("approachRadius", data.interactionRadius);
data.activateFunctionName =
item.value("activateFunction", "");
data.pivot = vector(item, "pivotX", "pivotY", "pivotZ");
data.boundsMin =
vector(item, "boundsMinX", "boundsMinY", "boundsMinZ");
data.boundsMax =
vector(item, "boundsMaxX", "boundsMaxY", "boundsMaxZ");
data.hasInteractionPosition =
item.contains("interactionPositionX");
if (data.hasInteractionPosition) {
data.interactionPosition = vector(
item, "interactionPositionX", "interactionPositionY",
"interactionPositionZ");
}
data.castShadow = item.value("castShadow", true);
data.castShadowNight = item.value("castShadowNight", true);
data.isActive = item.value("isActive", true);
result.interactiveObjects.push_back(std::move(data));
}
}
if (!config.npcsJsonPath.empty()) {
const json root = loadJson(config.npcsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("npcs", json::array())) {
NpcData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || data.id == "player" ||
!ids.insert(data.id).second) {
throw std::runtime_error(
"Missing, duplicate, or reserved NPC id in " +
config.npcsJsonPath);
}
data.name = item.value("name", "Unknown NPC");
data.texturePath = item.value("texturePath", "");
if (item.contains("meshTextures") &&
item["meshTextures"].is_object()) {
for (const auto& [name, path] :
item["meshTextures"].items()) {
if (path.is_string())
data.meshTextures[name] = path.get<std::string>();
}
}
data.interactionRadius =
item.value("interactionRadius", 0.f);
data.animationIdlePath =
item.value("animationIdlePath", "");
data.animationWalkPath =
item.value("animationWalkPath", "");
data.animationActionIdlePath =
item.value("animationActionIdlePath", "");
data.animationActionAttackPath =
item.value("animationActionAttackPath", "");
data.animationStandToActionPath =
item.value("animationStandToActionPath", "");
data.animationActionToStandPath =
item.value("animationActionToStandPath", "");
data.animationActionToDeathPath =
item.value("animationActionToDeathPath", "");
data.animationDeathIdlePath =
item.value("animationDeathIdlePath", "");
data.positionX = item.value("positionX", 0.f);
data.positionY = item.value("positionY", 0.f);
data.positionZ = item.value("positionZ", 0.f);
data.walkSpeed = item.value("walkSpeed", 1.5f);
data.rotationSpeed = item.value("rotationSpeed", 8.f);
data.modelScale = item.value("modelScale", 0.01f);
const float degrees = kPi / 180.f;
data.modelCorrectionRotX =
item.value("modelCorrectionRotX", 0.f) * degrees;
data.modelCorrectionRotY =
item.value("modelCorrectionRotY", 0.f) * degrees;
data.modelCorrectionRotZ =
item.value("modelCorrectionRotZ", 0.f) * degrees;
data.facingAngle =
item.value("facingAngle", 0.f) * degrees;
data.hp = item.value("hp", 100.f);
data.visibleRange =
item.value("visibleRange", VISIBLE_RANGE);
data.canAttack = item.value("canAttack", false);
data.enabled = item.value("enabled", true);
result.npcs.push_back(std::move(data));
}
}
if (!config.teleportsJsonPath.empty()) {
const json root = loadJson(config.teleportsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("teleports", json::array())) {
TeleportDefinition data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate teleport id in " +
config.teleportsJsonPath);
data.active = item.value("active", false);
data.position =
vector(item, "positionX", "positionY", "positionZ");
data.radius = item.value("radius", 0.f);
data.automatic = item.value("automatic", false);
data.automaticRadius =
item.value("automaticRadius", data.radius);
data.destinationLocation =
item.value("destinationLocation", "");
data.destinationPosition = vector(
item, "destinationPositionX", "destinationPositionY",
"destinationPositionZ");
data.destinationRotationY =
item.value("destinationRotationY", 0.f);
result.teleports.push_back(std::move(data));
}
}
if (!config.triggerZonesJsonPath.empty()) {
const json root = loadJson(config.triggerZonesJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item :
root.value("trigger_zones", json::array())) {
TriggerZoneDefinition data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate trigger-zone id in " +
config.triggerZonesJsonPath);
data.enabled = item.value("enabled", true);
data.radius = item.value("radius", 1.5f);
data.hysteresis = item.value("hysteresis", 0.3f);
data.position =
vector(item, "positionX", "positionY", "positionZ");
result.triggerZones.push_back(std::move(data));
}
}
if (!config.lightsJsonPath.empty()) {
const json root = loadJson(config.lightsJsonPath, zipFile);
for (const auto& item : root.value("lights", json::array())) {
PointLightDefinition data;
data.id = item.value("id", "");
data.position =
vector(item, "positionX", "positionY", "positionZ");
data.direction = vector(
item, "directionX", "directionY", "directionZ",
Eigen::Vector3f(0.f, -1.f, 0.f));
data.color = vector(
item, "colorR", "colorG", "colorB",
Eigen::Vector3f::Ones());
data.autoLight = item.value("autoLight", false);
data.autoLightDistance =
item.value("autoLightDistance", -1.f);
data.limitX = item.value("limitX", 99999.f);
data.limitY = item.value("limitY", 99999.f);
data.autoLightLimitX =
item.value("autoLightLimitX", -1.f);
data.autoLightLimitY =
item.value("autoLightLimitY", -1.f);
result.pointLights.push_back(std::move(data));
}
}
return result;
}
} // namespace FRG
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include "../Campaign.h"
#include "../CharacterState.h"
#include "../items/InteractiveObjectState.h"
#include <Eigen/Core>
#include <map>
#include <string>
#include <vector>
namespace FRG {
struct GameObjectData {
std::string name;
std::string texturePath;
std::string textureDarkandsPath;
std::string meshPath;
float rotationX = 0.f;
float rotationY = 0.f;
float rotationZ = 0.f;
float positionX = 0.f;
float positionY = 0.f;
float positionZ = 0.f;
float scale = 1.f;
bool instanced = true;
LoadedGameObjectState toLoadedGameObjectState() const;
};
struct InteractiveObjectData {
std::string id;
GameObjectData base;
float interactionRadius = 2.f;
float approachRadius = 2.f;
std::string activateFunctionName;
Eigen::Vector3f pivot = Eigen::Vector3f::Zero();
Eigen::Vector3f boundsMin = Eigen::Vector3f::Zero();
Eigen::Vector3f boundsMax = Eigen::Vector3f::Zero();
bool hasInteractionPosition = false;
Eigen::Vector3f interactionPosition = Eigen::Vector3f::Zero();
bool castShadow = true;
bool castShadowNight = true;
bool isActive = true;
InteractiveObjectState toInteractiveObjectState() const;
};
struct NpcData {
std::string id;
std::string name;
std::string texturePath;
std::map<std::string, std::string> meshTextures;
std::string animationIdlePath;
std::string animationWalkPath;
std::string animationActionIdlePath;
std::string animationActionAttackPath;
std::string animationStandToActionPath;
std::string animationActionToStandPath;
std::string animationActionToDeathPath;
std::string animationDeathIdlePath;
float positionX = 0.f;
float positionY = 0.f;
float positionZ = 0.f;
float walkSpeed = 1.5f;
float rotationSpeed = 8.f;
float modelScale = 0.01f;
float modelCorrectionRotX = 0.f;
float modelCorrectionRotY = 0.f;
float modelCorrectionRotZ = 0.f;
float facingAngle = 0.f;
float hp = 100.f;
float visibleRange = VISIBLE_RANGE;
bool canAttack = false;
bool enabled = true;
float interactionRadius = 0.f;
CharacterState toCharacterState() const;
};
struct TeleportDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 0.f;
bool active = false;
std::string destinationLocation;
Eigen::Vector3f destinationPosition = Eigen::Vector3f::Zero();
float destinationRotationY = 0.f;
bool automatic = false;
float automaticRadius = 0.f;
};
struct TriggerZoneDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 1.5f;
float hysteresis = 0.3f;
bool enabled = true;
};
struct PointLightDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
Eigen::Vector3f direction = Eigen::Vector3f(0.f, -1.f, 0.f);
Eigen::Vector3f color = Eigen::Vector3f::Ones();
bool autoLight = false;
float autoLightDistance = -1.f;
float limitX = 99999.f;
float limitY = 99999.f;
float autoLightLimitX = -1.f;
float autoLightLimitY = -1.f;
};
struct LocationDefinition {
LocationConfig config;
std::vector<GameObjectData> staticObjects;
std::vector<InteractiveObjectData> interactiveObjects;
std::vector<NpcData> npcs;
std::vector<TeleportDefinition> teleports;
std::vector<TriggerZoneDefinition> triggerZones;
std::vector<PointLightDefinition> pointLights;
};
class LocationContentLoader {
public:
static LocationDefinition load(
const LocationConfig& config, const std::string& zipFile = {});
};
void loadCharacterAnimationMetadata(
CharacterState& state, const std::string& zipFile = {});
} // namespace FRG
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "IGameServer.h"
#include <memory>
#include <string>
#include <vector>
class AudioPlayerAsync;
namespace FRG {
class Location;
struct LocationConfig;
struct LocationSetup;
// Client-only lifecycle and presentation integration for the in-process server.
// The public gameplay contract remains IGameServer and is graphics-free.
class IGameServerHost {
public:
virtual ~IGameServerHost() = default;
virtual void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) = 0;
virtual void clearCampaign() = 0;
virtual void resetCampaign(
const std::string& questsPath, const std::string& zipFile) = 0;
virtual bool prepareLocation(const LocationConfig& config) = 0;
virtual void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) = 0;
virtual Location* runtimeLocation(const std::string& id) const = 0;
virtual bool hasRuntimeLocation(const std::string& id) const = 0;
virtual std::vector<std::string> loadedRuntimeLocationIds() const = 0;
virtual void clearRuntimeLocations(bool retainState) = 0;
virtual void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) = 0;
virtual void update(std::int64_t deltaMs) = 0;
virtual void save(nlohmann::json& out) = 0;
virtual void load(const nlohmann::json& in) = 0;
};
} // namespace FRG
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
namespace FRG {
struct CampaignConfig;
struct LocationConfig;
// Graphics-free administrative lifecycle used by local and standalone hosts.
class IGameServerLifecycle {
public:
virtual ~IGameServerLifecycle() = default;
virtual void startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot) = 0;
virtual bool prepareLocation(const LocationConfig& config) = 0;
virtual void activateLocation(const std::string& locationId) = 0;
};
} // namespace FRG
+66 -120
View File
@@ -1,6 +1,6 @@
#include "InternalGameServer.h"
#include "../Campaign.h"
#include "../Location.h"
#include "../content/LocationDefinition.h"
#include <algorithm>
#include <random>
#include <type_traits>
@@ -8,8 +8,6 @@
namespace FRG {
namespace {
// Set this to false to disable the battle-zone remote-player test bot.
constexpr bool kEnableBattleZoneTestBot = true;
constexpr const char* kBattleZoneCampaignId = "battle_zone";
constexpr const char* kBattleZoneTestBotId = "battle_zone_test_bot";
constexpr std::int64_t kBattleZoneTestBotIntervalMs = 5000;
@@ -20,7 +18,7 @@ InternalGameServer::InternalGameServer() = default;
void InternalGameServer::ensureBattleZoneTestBot()
{
if (state_.campaignId != kBattleZoneCampaignId) return;
if (!kEnableBattleZoneTestBot) {
if (!battleZoneTestBotEnabled_) {
state_.players.erase(kBattleZoneTestBotId);
return;
}
@@ -33,7 +31,7 @@ void InternalGameServer::ensureBattleZoneTestBot()
void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs)
{
if (!kEnableBattleZoneTestBot ||
if (!battleZoneTestBotEnabled_ ||
state_.campaignId != kBattleZoneCampaignId ||
state_.currentLocationName.empty() ||
state_.players.find(kBattleZoneTestBotId) == state_.players.end()) {
@@ -57,24 +55,66 @@ void InternalGameServer::initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile)
{
state_.campaignId = campaignId;
CampaignConfig campaign;
campaign.id = campaignId;
campaign.questsPath = questsPath;
startCampaign(campaign, zipFile, true, true);
}
void InternalGameServer::startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot)
{
resourceZipFile_ = zipFile;
battleZoneTestBotEnabled_ = enableTestBot;
state_.campaignId = campaign.id;
state_.currentLocationName.clear();
state_.locations.clear();
state_.players.clear();
state_.questJournal.loadFromFile(questsPath, zipFile);
state_.questJournal.loadFromFile(campaign.questsPath, zipFile);
state_.globalFloats["player_hp"] = 200.f;
auto player = std::make_shared<CharacterState>();
player->configureAsPlayer();
state_.players.emplace(localPlayerId_, std::move(player));
if (createLocalPlayer) {
auto player = std::make_shared<CharacterState>();
player->configureAsPlayer();
player->creationInfo.animationPaths = {
{AnimationState::STAND,
"resources/w/gg/new2/gg_stand_idle001_small.anim"},
{AnimationState::WALK,
"resources/w/gg/new2/gg_walk001_small.anim"},
{AnimationState::STAND_TO_ACTION,
"resources/w/gg/new2/gg_stand_to_action001_small.anim"},
{AnimationState::ACTION_ATTACK,
"resources/w/gg/new2/gg_action_chop001_small.anim"},
{AnimationState::ACTION_ATTACK_2,
"resources/w/gg/new2/gg_action_stab001_small.anim"},
{AnimationState::ACTION_IDLE,
"resources/w/gg/new2/gg_action_idle002_small.anim"},
{AnimationState::ACTION_TO_STAND,
"resources/w/gg/new2/gg_action_to_stand001_small.anim"},
{AnimationState::ACTION_TO_DEATH,
"resources/w/gg/new2/gg_die001_small.anim"},
{AnimationState::DEATH_IDLE,
"resources/w/gg/new2/gg_die_idle001_small.anim"}};
loadCharacterAnimationMetadata(*player, zipFile);
state_.players.emplace(localPlayerId_, std::move(player));
}
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
publishMutation();
}
void InternalGameServer::activateLocation(const std::string& locationId)
{
if (state_.locations.count(locationId) == 0)
throw std::runtime_error(
"Cannot activate unprepared location '" + locationId + "'");
state_.currentLocationName = locationId;
publishMutation();
}
void InternalGameServer::clearCampaign()
{
runtimeLocations_.clear();
state_.currentLocationName.clear();
state_.players.clear();
state_.locations.clear();
@@ -117,76 +157,20 @@ void InternalGameServer::resetCampaign(
bool InternalGameServer::prepareLocation(const LocationConfig& config)
{
LocationGameState& authoritative = state_.locations[config.id];
const bool initializeLocation = !authoritative.hasPersistentState;
if (!authoritative.location)
authoritative.location = std::make_shared<LocationState>();
if (authoritative.location->navigationMaps.empty())
authoritative.location->initNavigation(config.navigationJsonPaths);
authoritative.lockInteractiveObjectsDuringTutorial =
config.lockInteractiveObjectsDuringTutorial;
if (config.taxi) {
const LocationConfig::Taxi& taxi = *config.taxi;
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 (initializeLocation) authoritative.resetTaxi();
const bool initializeLocation = authoritative.setup(
LocationContentLoader::load(config, resourceZipFile_),
resourceZipFile_);
auto player = state_.players.find(localPlayerId_);
if (player != state_.players.end() && player->second) {
player->second->position = config.playerPosition;
player->second->setTarget(
config.playerPosition, std::string(), *authoritative.location,
localPlayerId_);
}
publishMutation();
return initializeLocation;
}
void InternalGameServer::setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer)
{
auto stored = state_.locations.find(id);
if (stored == state_.locations.end())
throw std::runtime_error("Location state was not prepared");
params.gameState = &stored->second;
location->setup(params, audioPlayer);
publishMutation();
}
Location* InternalGameServer::runtimeLocation(const std::string& id) const
{
auto it = runtimeLocations_.find(id);
return it != runtimeLocations_.end() ? it->second.get() : nullptr;
}
bool InternalGameServer::hasRuntimeLocation(const std::string& id) const
{
return runtimeLocations_.count(id) > 0;
}
std::vector<std::string> InternalGameServer::loadedRuntimeLocationIds() const
{
std::vector<std::string> result;
result.reserve(runtimeLocations_.size());
for (const auto& [id, location] : runtimeLocations_) result.push_back(id);
return result;
}
void InternalGameServer::clearRuntimeLocations(bool retainState)
{
if (retainState) {
synchronizeRuntimeAuxiliaryState();
} else {
for (const auto& [id, location] : runtimeLocations_)
state_.locations.erase(id);
publishMutation();
}
runtimeLocations_.clear();
}
GameSnapshot InternalGameServer::buildSnapshot() const
{
GameSnapshot result;
@@ -472,11 +456,8 @@ void InternalGameServer::submit(const GameCommand& command)
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end()) {
location->second.triggerZones[value.zoneId].enabled = value.enabled;
auto runtime = runtimeLocations_.find(value.locationId);
if (runtime != runtimeLocations_.end() && runtime->second) {
for (auto& zone : runtime->second->triggerZones)
if (zone.id == value.zoneId) zone.enabled = value.enabled;
}
pendingTriggerZoneUpdates_.emplace_back(
value.locationId, value.zoneId);
}
} else if constexpr (std::is_same_v<T, ReloadQuestDefinitionsCommand>) {
state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile);
@@ -494,26 +475,22 @@ std::vector<GameServerEvent> InternalGameServer::drainEvents()
void InternalGameServer::update(std::int64_t deltaMs)
{
auto runtime = runtimeLocations_.find(state_.currentLocationName);
if (runtime == runtimeLocations_.end() || !runtime->second) return;
pendingSimulationLocationId_.clear();
pendingSimulationEvents_ = {};
auto stored = state_.locations.find(state_.currentLocationName);
if (stored == state_.locations.end()) return;
updateBattleZoneTestBot(deltaMs);
const bool interactiveObjectsLocked =
stored->second.lockInteractiveObjectsDuringTutorial &&
state_.tutorialStep < TutorialStep::PickUpItem;
LocationSimulationEvents events =
pendingSimulationLocationId_ = state_.currentLocationName;
pendingSimulationEvents_ =
stored->second.update(deltaMs, state_.players, interactiveObjectsLocked);
// Runtime presentation reads through snapshots, so publish the simulation
// tick before forwarding its value-only events to the loaded location.
publishMutation();
runtime->second->update(deltaMs, events);
synchronizeRuntimeAuxiliaryState();
}
void InternalGameServer::save(nlohmann::json& out) const
{
const_cast<InternalGameServer*>(this)->synchronizeRuntimeAuxiliaryState();
state_.save(out);
}
@@ -523,40 +500,9 @@ void InternalGameServer::load(const nlohmann::json& in)
state_.load(in);
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
applyStateToRuntime();
publishMutation();
}
void InternalGameServer::bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = state_.locations.find(id);
if (stored != state_.locations.end() && stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
} else {
runtimeLocations_[id]->captureAuxiliaryState(state_.locations[id]);
}
publishMutation();
}
void InternalGameServer::synchronizeRuntimeAuxiliaryState()
{
for (const auto& [id, location] : runtimeLocations_) {
if (!location) continue;
location->captureAuxiliaryState(state_.locations[id]);
}
publishMutation();
}
void InternalGameServer::applyStateToRuntime()
{
for (const auto& [id, stored] : state_.locations) {
auto runtime = runtimeLocations_.find(id);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->applyAuxiliaryState(stored);
}
}
void InternalGameServer::publishMutation()
{
++revision_;
+15 -20
View File
@@ -1,20 +1,17 @@
#pragma once
#include "IGameServer.h"
class AudioPlayerAsync;
#include "IGameServerLifecycle.h"
namespace FRG {
class Game;
class Character;
class Location;
class LocalGameServerHost;
struct LocationConfig;
struct LocationSetup;
// In-process implementation used by single-player. GameState exists exactly
// once, here; clients see immutable snapshots and mutate it with typed commands.
class InternalGameServer final : public IGameServer {
class InternalGameServer final
: public IGameServer, public IGameServerLifecycle {
public:
InternalGameServer();
@@ -25,9 +22,14 @@ public:
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
void startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot) override;
bool prepareLocation(const LocationConfig& config) override;
void activateLocation(const std::string& locationId) override;
private:
friend class Game;
friend class LocalGameServerHost;
// Host-only lifecycle operations. They never expose authoritative state and
// are intentionally absent from IGameServer.
@@ -36,17 +38,6 @@ private:
const std::string& zipFile);
void clearCampaign();
void resetCampaign(const std::string& questsPath, const std::string& zipFile);
bool prepareLocation(const LocationConfig& config);
void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer);
Location* runtimeLocation(const std::string& id) const;
bool hasRuntimeLocation(const std::string& id) const;
std::vector<std::string> loadedRuntimeLocationIds() const;
void clearRuntimeLocations(bool retainState);
void bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location);
void synchronizeRuntimeAuxiliaryState();
void applyStateToRuntime();
void publishMutation();
void ensureBattleZoneTestBot();
void updateBattleZoneTestBot(std::int64_t deltaMs);
@@ -55,8 +46,12 @@ private:
GameState state_;
std::string localPlayerId_ = "player";
std::string resourceZipFile_;
std::int64_t battleZoneTestBotElapsedMs_ = 0;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
bool battleZoneTestBotEnabled_ = false;
std::string pendingSimulationLocationId_;
LocationSimulationEvents pendingSimulationEvents_;
std::vector<std::pair<std::string, std::string>> pendingTriggerZoneUpdates_;
std::uint64_t revision_ = 1;
mutable std::uint64_t cachedRevision_ = 0;
mutable std::shared_ptr<const GameSnapshot> cachedSnapshot_;
+152
View File
@@ -0,0 +1,152 @@
#include "LocalGameServerHost.h"
#include "InternalGameServer.h"
#include "../Campaign.h"
#include "../Location.h"
#include <stdexcept>
namespace FRG {
LocalGameServerHost::LocalGameServerHost(InternalGameServer& server)
: server_(server)
{
}
void LocalGameServerHost::initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile)
{
server_.initializeCampaign(campaignId, questsPath, zipFile);
}
void LocalGameServerHost::clearCampaign()
{
runtimeLocations_.clear();
server_.clearCampaign();
}
void LocalGameServerHost::resetCampaign(
const std::string& questsPath, const std::string& zipFile)
{
server_.resetCampaign(questsPath, zipFile);
}
bool LocalGameServerHost::prepareLocation(const LocationConfig& config)
{
return server_.prepareLocation(config);
}
void LocalGameServerHost::setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer)
{
auto stored = server_.state_.locations.find(id);
if (stored == server_.state_.locations.end())
throw std::runtime_error("Location state was not prepared");
params.definition = stored->second.definition;
params.gameState = &stored->second;
location->setup(params, audioPlayer);
server_.publishMutation();
}
Location* LocalGameServerHost::runtimeLocation(const std::string& id) const
{
auto it = runtimeLocations_.find(id);
return it != runtimeLocations_.end() ? it->second.get() : nullptr;
}
bool LocalGameServerHost::hasRuntimeLocation(const std::string& id) const
{
return runtimeLocations_.count(id) > 0;
}
std::vector<std::string> LocalGameServerHost::loadedRuntimeLocationIds() const
{
std::vector<std::string> result;
result.reserve(runtimeLocations_.size());
for (const auto& [id, location] : runtimeLocations_) result.push_back(id);
return result;
}
void LocalGameServerHost::clearRuntimeLocations(bool retainState)
{
if (retainState) {
synchronizeRuntimeAuxiliaryState();
} else {
for (const auto& [id, location] : runtimeLocations_)
server_.state_.locations.erase(id);
server_.publishMutation();
}
runtimeLocations_.clear();
}
void LocalGameServerHost::bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = server_.state_.locations.find(id);
if (stored != server_.state_.locations.end() &&
stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
} else {
runtimeLocations_[id]->captureAuxiliaryState(server_.state_.locations[id]);
}
server_.publishMutation();
}
void LocalGameServerHost::update(std::int64_t deltaMs)
{
server_.update(deltaMs);
for (const auto& [locationId, zoneId] : server_.pendingTriggerZoneUpdates_) {
auto runtime = runtimeLocations_.find(locationId);
auto stored = server_.state_.locations.find(locationId);
if (runtime == runtimeLocations_.end() || !runtime->second ||
stored == server_.state_.locations.end()) {
continue;
}
auto zoneState = stored->second.triggerZones.find(zoneId);
if (zoneState == stored->second.triggerZones.end()) continue;
for (auto& zone : runtime->second->triggerZones)
if (zone.id == zoneId) zone.enabled = zoneState->second.enabled;
}
server_.pendingTriggerZoneUpdates_.clear();
if (server_.pendingSimulationLocationId_.empty()) return;
auto runtime =
runtimeLocations_.find(server_.pendingSimulationLocationId_);
if (runtime == runtimeLocations_.end() || !runtime->second) return;
runtime->second->update(deltaMs, server_.pendingSimulationEvents_);
synchronizeRuntimeAuxiliaryState();
}
void LocalGameServerHost::save(nlohmann::json& out)
{
synchronizeRuntimeAuxiliaryState();
server_.save(out);
}
void LocalGameServerHost::load(const nlohmann::json& in)
{
server_.load(in);
applyStateToRuntime();
}
void LocalGameServerHost::synchronizeRuntimeAuxiliaryState()
{
for (const auto& [id, location] : runtimeLocations_) {
if (!location) continue;
location->captureAuxiliaryState(server_.state_.locations[id]);
}
server_.publishMutation();
}
void LocalGameServerHost::applyStateToRuntime()
{
for (const auto& [id, stored] : server_.state_.locations) {
auto runtime = runtimeLocations_.find(id);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->applyAuxiliaryState(stored);
}
}
} // namespace FRG
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "IGameServerHost.h"
#include <unordered_map>
namespace FRG {
class InternalGameServer;
// Adapts the headless authoritative server to the graphical in-process client.
class LocalGameServerHost final : public IGameServerHost {
public:
explicit LocalGameServerHost(InternalGameServer& server);
void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) override;
void clearCampaign() override;
void resetCampaign(
const std::string& questsPath, const std::string& zipFile) override;
bool prepareLocation(const LocationConfig& config) override;
void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) override;
Location* runtimeLocation(const std::string& id) const override;
bool hasRuntimeLocation(const std::string& id) const override;
std::vector<std::string> loadedRuntimeLocationIds() const override;
void clearRuntimeLocations(bool retainState) override;
void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) override;
void load(const nlohmann::json& in) override;
private:
void synchronizeRuntimeAuxiliaryState();
void applyStateToRuntime();
InternalGameServer& server_;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
};
} // namespace FRG
+68 -158
View File
@@ -102,20 +102,21 @@ namespace FRG {
data.castShadowNight = item.value("castShadowNight", true);
data.isActive = item.value("isActive", true);
data.activateFunctionName = item.value("activateFunction", "");
data.pivotX = item.value("pivotX", 0.0f);
data.pivotY = item.value("pivotY", 0.0f);
data.pivotZ = item.value("pivotZ", 0.0f);
data.boundsMinX = item.value("boundsMinX", 0.0f);
data.boundsMinY = item.value("boundsMinY", 0.0f);
data.boundsMinZ = item.value("boundsMinZ", 0.0f);
data.boundsMaxX = item.value("boundsMaxX", 0.0f);
data.boundsMaxY = item.value("boundsMaxY", 0.0f);
data.boundsMaxZ = item.value("boundsMaxZ", 0.0f);
data.pivot = {
item.value("pivotX", 0.0f), item.value("pivotY", 0.0f),
item.value("pivotZ", 0.0f)};
data.boundsMin = {
item.value("boundsMinX", 0.0f), item.value("boundsMinY", 0.0f),
item.value("boundsMinZ", 0.0f)};
data.boundsMax = {
item.value("boundsMaxX", 0.0f), item.value("boundsMaxY", 0.0f),
item.value("boundsMaxZ", 0.0f)};
if (item.contains("interactionPositionX")) {
data.hasInteractionPosition = true;
data.interactionPositionX = item.value("interactionPositionX", 0.0f);
data.interactionPositionY = item.value("interactionPositionY", 0.0f);
data.interactionPositionZ = item.value("interactionPositionZ", 0.0f);
data.interactionPosition = {
item.value("interactionPositionX", 0.0f),
item.value("interactionPositionY", 0.0f),
item.value("interactionPositionZ", 0.0f)};
}
if (!data.base.meshPath.empty())
objects.push_back(std::move(data));
@@ -125,40 +126,6 @@ namespace FRG {
return objects;
}
LoadedGameObjectState GameObjectData::toLoadedGameObjectState() const {
LoadedGameObjectState st;
st.texturePath = texturePath;
st.textureDarkandsPath = textureDarkandsPath;
st.meshPath = meshPath;
st.meshRotationX = rotationX;
st.meshRotationY = rotationY;
st.meshRotationZ = rotationZ;
st.meshScale = scale;
return st;
}
InteractiveObjectState InteractiveObjectData::toInteractiveObjectState() const {
InteractiveObjectState st;
st.objectInfo = base.toLoadedGameObjectState();
st.interactionRadius = interactionRadius;
st.approachRadius = approachRadius;
st.castShadow = castShadow;
st.castShadowNight = castShadowNight;
st.isActive = isActive;
st.activateFunctionName = activateFunctionName;
st.pivot = Eigen::Vector3f(pivotX, pivotY, pivotZ);
st.boundsMin = Eigen::Vector3f(boundsMinX, boundsMinY, boundsMinZ);
st.boundsMax = Eigen::Vector3f(boundsMaxX, boundsMaxY, boundsMaxZ);
st.hasInteractionPosition = hasInteractionPosition;
if (hasInteractionPosition)
st.interactionPosition = Eigen::Vector3f(interactionPositionX, interactionPositionY, interactionPositionZ);
st.jsonPositionX = base.positionX;
st.jsonPositionY = base.positionY;
st.jsonPositionZ = base.positionZ;
// state.position is set by InteractiveObject::createFromState() after mesh-center computation
return st;
}
static VertexDataStruct loadMeshData(const std::string& meshPath, const std::string& zipPath)
{
if (meshPath.size() > 4 && meshPath.compare(meshPath.size() - 4, 4, ".bin") == 0)
@@ -213,20 +180,18 @@ namespace FRG {
// Public API
// -----------------------------------------------------------------------
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
const std::string& jsonPath,
StaticObjectSet GameObjectLoader::createGameObjects(
const std::vector<GameObjectData>& definitions,
Renderer& renderer,
const std::string& zipPath)
{
StaticObjectSet result;
std::vector<GameObjectData> allData = loadFromJson(jsonPath, zipPath);
// Group objects sharing mesh + textures (in file order) so they can be drawn instanced.
std::vector<std::vector<const GameObjectData*>> groups;
std::unordered_map<std::string, size_t> groupIndexByKey;
for (const auto& data : allData) {
for (const auto& data : definitions) {
if (!data.instanced) {
groups.push_back({ &data });
continue;
@@ -294,45 +259,32 @@ namespace FRG {
return result;
}
std::vector<InteractiveObject> GameObjectLoader::loadAndCreateInteractiveObjects(
const std::string& jsonPath,
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
const std::string& jsonPath, Renderer& renderer,
const std::string& zipPath)
{
return createGameObjects(
loadFromJson(jsonPath, zipPath), renderer, zipPath);
}
std::vector<InteractiveObject> GameObjectLoader::createInteractiveObjects(
const std::vector<InteractiveObjectData>& definitions,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath)
{
std::vector<InteractiveObject> interactiveObjects;
if (jsonPath.empty())
return interactiveObjects;
const auto definitions = loadInteractiveFromJson(jsonPath, zipPath);
std::unordered_set<std::string> configuredIds;
for (const auto& data : definitions) {
if (!configuredIds.insert(data.id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate interactive object id '" +
data.id + "' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (configuredIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved interactive object id '" + id +
"' is not present in " + jsonPath);
for (const auto& data : definitions) {
SDL_PumpEvents();
logger() << "Loading interactive object: " << data.base.name << std::endl;
try {
InteractiveObjectState definition = data.toInteractiveObjectState();
auto& state = states[data.id];
if (!state) state = std::make_shared<InteractiveObjectState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
auto state = states.find(data.id);
if (state == states.end() || !state->second)
throw std::runtime_error(
"Authoritative state missing for '" + data.id + "'");
InteractiveObject intObj =
InteractiveObject::createFromState(*state, renderer, zipPath);
InteractiveObject::createFromState(
*state->second, renderer, zipPath);
intObj.id = data.id;
intObj.loadedObject.name = data.base.name;
@@ -352,6 +304,18 @@ namespace FRG {
return interactiveObjects;
}
std::vector<InteractiveObject>
GameObjectLoader::loadAndCreateInteractiveObjects(
const std::string& jsonPath, Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath)
{
return createInteractiveObjects(
loadInteractiveFromJson(jsonPath, zipPath), renderer, states,
zipPath);
}
std::vector<NpcData> GameObjectLoader::loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath)
{
std::vector<NpcData> npcs;
@@ -412,96 +376,29 @@ namespace FRG {
return npcs;
}
CharacterState NpcData::toCharacterState() const {
CharacterState st;
// Identity + game state
st.npcName = name;
st.position = Eigen::Vector3f(positionX, positionY, positionZ);
st.homePosition = Eigen::Vector3f(positionX, 0.f, positionZ);
st.stopInPlace();
st.facingAngle = facingAngle;
st.walkSpeed = walkSpeed;
st.rotationSpeed = rotationSpeed;
st.modelScale = modelScale;
st.setHp(hp);
st.initialHp = hp;
st.visibleRange = visibleRange;
st.canAttack = canAttack;
if (canAttack) st.attackTargetId = "player";
st.enabled = enabled;
st.interactionRadius = interactionRadius;
Eigen::Quaternionf corrRot = Eigen::Quaternionf::Identity();
if (modelCorrectionRotX != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotX, Eigen::Vector3f::UnitX())) * corrRot;
if (modelCorrectionRotY != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotY, Eigen::Vector3f::UnitY())) * corrRot;
if (modelCorrectionRotZ != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotZ, Eigen::Vector3f::UnitZ())) * corrRot;
st.modelCorrectionRotation = corrRot;
// Creation info: animation paths
CharacterCreationInfo& info = st.creationInfo;
auto addAnim = [&](AnimationState animState, const std::string& path) {
if (!path.empty())
info.animationPaths.emplace_back(animState, path);
};
addAnim(AnimationState::STAND, animationIdlePath);
addAnim(AnimationState::WALK, animationWalkPath);
addAnim(AnimationState::ACTION_IDLE, animationActionIdlePath);
addAnim(AnimationState::ACTION_ATTACK, animationActionAttackPath);
addAnim(AnimationState::STAND_TO_ACTION, animationStandToActionPath);
addAnim(AnimationState::ACTION_TO_STAND, animationActionToStandPath);
addAnim(AnimationState::ACTION_TO_DEATH, animationActionToDeathPath);
addAnim(AnimationState::DEATH_IDLE, animationDeathIdlePath);
// Creation info: textures
info.meshTexturePaths = std::unordered_map<std::string, std::string>(meshTextures.begin(), meshTextures.end());
info.singleTexturePath = texturePath;
return st;
}
std::vector<std::unique_ptr<Character>> GameObjectLoader::loadAndCreate_Npcs(
const std::string& jsonPath,
std::vector<std::unique_ptr<Character>> GameObjectLoader::createNpcs(
const std::vector<NpcData>& definitions,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<CharacterState>>& states,
const std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath)
{
std::vector<std::unique_ptr<Character>> npcs;
const auto definitions = loadNpcsFromJson(jsonPath, zipPath);
std::unordered_set<std::string> seenIds;
for (const auto& definition : definitions) {
const std::string& id = definition.id;
if (id == "player" || !seenIds.insert(id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate or reserved NPC id '" + id +
"' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (seenIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved NPC id '" + id +
"' is not present in " + jsonPath);
for (const auto& npcData : definitions) {
SDL_PumpEvents();
logger() << "Loading NPC: " << npcData.name << std::endl;
try {
CharacterState definition = npcData.toCharacterState();
auto& state = states[npcData.id];
if (!state) state = std::make_shared<CharacterState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
auto state = states.find(npcData.id);
if (state == states.end() || !state->second)
throw std::runtime_error(
"Authoritative state missing for '" +
npcData.id + "'");
auto npc = Character::createFromState(
*state, server, locationId, npcData.id, renderer, zipPath.c_str());
*state->second, server, locationId, npcData.id,
renderer, zipPath.c_str());
if (npc) {
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
@@ -515,4 +412,17 @@ namespace FRG {
return npcs;
}
std::vector<std::unique_ptr<Character>>
GameObjectLoader::loadAndCreate_Npcs(
const std::string& jsonPath, Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server, const std::string& locationId,
const std::string& zipPath)
{
return createNpcs(
loadNpcsFromJson(jsonPath, zipPath), renderer, states, server,
locationId, zipPath);
}
} // namespace FRG
+18 -74
View File
@@ -9,31 +9,13 @@
#include "InteractiveObject.h"
#include "InteractiveObjectState.h"
#include "../CharacterState.h"
#include "../content/LocationDefinition.h"
namespace FRG {
class IGameServer;
class Character;
struct GameObjectData {
std::string name;
std::string texturePath;
std::string textureDarkandsPath;
std::string meshPath;
float rotationX = 0.0f;
float rotationY = 0.0f;
float rotationZ = 0.0f;
float positionX = 0.0f;
float positionY = 0.0f;
float positionZ = 0.0f;
float scale = 1.0f;
// Objects sharing mesh and textures are drawn with one instanced call.
// Set "instanced": false in the JSON to keep an object as a standalone mesh.
bool instanced = true;
LoadedGameObjectState toLoadedGameObjectState() const;
};
// Result of loading a static object list: unique objects keep their own mesh,
// repeated ones are merged into instanced batches.
struct StaticObjectSet {
@@ -41,63 +23,25 @@ namespace FRG {
std::vector<InstancedBatch> batches;
};
struct InteractiveObjectData {
std::string id;
GameObjectData base;
float interactionRadius = 2.0f;
float approachRadius = 2.0f;
std::string activateFunctionName;
float pivotX = 0.0f;
float pivotY = 0.0f;
float pivotZ = 0.0f;
float boundsMinX = 0.0f, boundsMinY = 0.0f, boundsMinZ = 0.0f;
float boundsMaxX = 0.0f, boundsMaxY = 0.0f, boundsMaxZ = 0.0f;
bool hasInteractionPosition = false;
float interactionPositionX = 0.0f, interactionPositionY = 0.0f, interactionPositionZ = 0.0f;
bool castShadow = true;
bool castShadowNight = true;
bool isActive = true;
InteractiveObjectState toInteractiveObjectState() const;
};
struct NpcData {
std::string id;
std::string name;
std::string texturePath;
std::map<std::string, std::string> meshTextures;
std::string animationIdlePath;
std::string animationWalkPath;
// Optional combat / death animations. Empty path = slot not loaded.
std::string animationActionIdlePath;
std::string animationActionAttackPath;
std::string animationStandToActionPath;
std::string animationActionToStandPath;
std::string animationActionToDeathPath;
std::string animationDeathIdlePath;
float positionX = 0.0f;
float positionY = 0.0f;
float positionZ = 0.0f;
float walkSpeed = 1.5f;
float rotationSpeed = 8.0f;
float modelScale = 0.01f;
float modelCorrectionRotX = 0.0f;
float modelCorrectionRotY = 0.0f;
float modelCorrectionRotZ = 0.0f;
float facingAngle = 0.0f;
float hp = 100.0f;
float visibleRange = VISIBLE_RANGE;
bool canAttack = false;
bool enabled = true;
float interactionRadius = 0.0f;
// Converts this JSON-parsed descriptor into a fully-populated CharacterState
// (including creationInfo) ready for Character::createFromState().
CharacterState toCharacterState() const;
};
class GameObjectLoader {
public:
static StaticObjectSet createGameObjects(
const std::vector<GameObjectData>& definitions,
Renderer& renderer, const std::string& zipPath = "");
static std::vector<InteractiveObject> createInteractiveObjects(
const std::vector<InteractiveObjectData>& definitions,
Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath = "");
static std::vector<std::unique_ptr<FRG::Character>> createNpcs(
const std::vector<NpcData>& definitions,
Renderer& renderer,
const std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server, const std::string& locationId,
const std::string& zipPath);
static StaticObjectSet loadAndCreateGameObjects(
const std::string& jsonPath,
Renderer& renderer,
+2 -180
View File
@@ -51,7 +51,8 @@ namespace FRG {
obj.loadedObject.mesh.RefreshVBO();
// Center mesh at origin; adjust world position by the centering offset.
// Interactive-object definitions store the centered world position.
// Center the presentation mesh at the origin without changing state.
if (!obj.loadedObject.mesh.data.PositionData.empty()) {
Eigen::Vector3f meshMin = obj.loadedObject.mesh.data.PositionData[0];
Eigen::Vector3f meshMax = obj.loadedObject.mesh.data.PositionData[0];
@@ -62,129 +63,11 @@ namespace FRG {
const Eigen::Vector3f meshCenter = (meshMin + meshMax) * 0.5f;
obj.loadedObject.mesh.data.Move(-meshCenter);
obj.loadedObject.mesh.RefreshVBO();
obj.state.position = Eigen::Vector3f(obj.state.jsonPositionX,
obj.state.jsonPositionY,
obj.state.jsonPositionZ) + meshCenter;
} else {
obj.state.position = Eigen::Vector3f(obj.state.jsonPositionX,
obj.state.jsonPositionY,
obj.state.jsonPositionZ);
}
return obj;
}
void InteractiveObjectState::moveTo(
const Eigen::Vector3f& target, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = target;
task.targetRotY = rotationY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::rotateTo(
float targetRotY, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = targetRotY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::scaleTo(
float targetScale, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Scale;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetScale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::fadeTo(
float targetAlpha, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Fade;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = alpha; // reuse startScale to hold start alpha
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetAlpha; // reuse targetScale to hold target alpha
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
InteractiveObjectUpdateEvents InteractiveObjectState::update(std::int64_t deltaMs) {
InteractiveObjectUpdateEvents events;
if (!isAnimating || !animTask) return events;
AnimTask& task = *animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float t = (std::min)(task.elapsedMs / task.durationMs, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t >= 1.0f) {
position = task.targetPos;
rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
alpha = task.targetScale;
else
scale = task.targetScale;
events.onCompleteName = std::move(task.onCompleteName);
animTask.reset();
isAnimating = false;
}
return events;
}
void InteractiveObject::drawDarklands(Renderer& renderer) const {
if (!state.isActive || !loadedObject.textureDarklands) return;
@@ -237,65 +120,4 @@ namespace FRG {
renderer.RenderUniform1f("uAlpha", 1.0f);
}
void InteractiveObjectState::save(nlohmann::json& out) const
{
out["positionX"] = position.x();
out["positionY"] = position.y();
out["positionZ"] = position.z();
out["rotationY"] = rotationY;
out["scale"] = scale;
out["alpha"] = alpha;
out["isActive"] = isActive;
out["isAnimating"] = isAnimating;
if (isAnimating && animTask.has_value()) {
nlohmann::json at;
at["type"] = static_cast<int>(animTask->type);
at["startPosX"] = animTask->startPos.x();
at["startPosY"] = animTask->startPos.y();
at["startPosZ"] = animTask->startPos.z();
at["startRotY"] = animTask->startRotY;
at["startScale"] = animTask->startScale;
at["targetPosX"] = animTask->targetPos.x();
at["targetPosY"] = animTask->targetPos.y();
at["targetPosZ"] = animTask->targetPos.z();
at["targetRotY"] = animTask->targetRotY;
at["targetScale"] = animTask->targetScale;
at["durationMs"] = animTask->durationMs;
at["elapsedMs"] = animTask->elapsedMs;
if (!animTask->onCompleteName.empty())
at["onCompleteName"] = animTask->onCompleteName;
out["animTask"] = std::move(at);
}
}
void InteractiveObjectState::load(const nlohmann::json& in)
{
position.x() = in.value("positionX", position.x());
position.y() = in.value("positionY", position.y());
position.z() = in.value("positionZ", position.z());
rotationY = in.value("rotationY", rotationY);
scale = in.value("scale", scale);
alpha = in.value("alpha", alpha);
isActive = in.value("isActive", isActive);
isAnimating = in.value("isAnimating", false);
animTask.reset();
if (isAnimating && in.contains("animTask") && in["animTask"].is_object()) {
const auto& at = in["animTask"];
AnimTask task;
task.type = static_cast<AnimTask::Type>(at.value("type", 0));
task.startPos = { at.value("startPosX", 0.f), at.value("startPosY", 0.f), at.value("startPosZ", 0.f) };
task.startRotY = at.value("startRotY", 0.f);
task.startScale = at.value("startScale", 1.f);
task.targetPos = { at.value("targetPosX", 0.f), at.value("targetPosY", 0.f), at.value("targetPosZ", 0.f) };
task.targetRotY = at.value("targetRotY", 0.f);
task.targetScale = at.value("targetScale", 1.f);
task.durationMs = at.value("durationMs", 1000.f);
task.elapsedMs = at.value("elapsedMs", 0.f);
task.onCompleteName = at.value("onCompleteName", std::string());
animTask = std::move(task);
}
}
} // namespace FRG
+1 -3
View File
@@ -23,8 +23,7 @@ namespace FRG {
LoadedGameObject loadedObject; // rendering assets
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
// Loads textures and mesh from state.objectInfo, computes mesh centering,
// and sets state.position = jsonPosition + meshCenter.
// Loads textures and centers the mesh around the state position.
static InteractiveObject createFromState(InteractiveObjectState& state,
Renderer& renderer,
const std::string& zipPath = "");
@@ -38,7 +37,6 @@ namespace FRG {
friend class Location;
friend class LocationEditor;
friend class ScriptEngine;
friend class InternalGameServer;
InteractiveObjectState& state; // non-owning binding to LocationGameState
};
+181
View File
@@ -0,0 +1,181 @@
#include "InteractiveObjectState.h"
#include <algorithm>
#include <utility>
namespace FRG {
void InteractiveObjectState::moveTo(
const Eigen::Vector3f& target, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = target;
task.targetRotY = rotationY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::rotateTo(
float targetRotY, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = targetRotY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::scaleTo(
float targetScale, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Scale;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetScale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::fadeTo(
float targetAlpha, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Fade;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = alpha;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetAlpha;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
InteractiveObjectUpdateEvents InteractiveObjectState::update(std::int64_t deltaMs)
{
InteractiveObjectUpdateEvents events;
if (!isAnimating || !animTask) return events;
AnimTask& task = *animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float duration = (std::max)(task.durationMs, 0.001f);
const float t = (std::min)(task.elapsedMs / duration, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t < 1.0f) return events;
position = task.targetPos;
rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
alpha = task.targetScale;
else
scale = task.targetScale;
events.onCompleteName = std::move(task.onCompleteName);
animTask.reset();
isAnimating = false;
return events;
}
void InteractiveObjectState::save(nlohmann::json& out) const
{
out["positionX"] = position.x();
out["positionY"] = position.y();
out["positionZ"] = position.z();
out["rotationY"] = rotationY;
out["scale"] = scale;
out["alpha"] = alpha;
out["isActive"] = isActive;
out["isAnimating"] = isAnimating;
if (isAnimating && animTask) {
out["animTask"] = {
{"type", static_cast<int>(animTask->type)},
{"startPosX", animTask->startPos.x()},
{"startPosY", animTask->startPos.y()},
{"startPosZ", animTask->startPos.z()},
{"startRotY", animTask->startRotY},
{"startScale", animTask->startScale},
{"targetPosX", animTask->targetPos.x()},
{"targetPosY", animTask->targetPos.y()},
{"targetPosZ", animTask->targetPos.z()},
{"targetRotY", animTask->targetRotY},
{"targetScale", animTask->targetScale},
{"durationMs", animTask->durationMs},
{"elapsedMs", animTask->elapsedMs},
{"onCompleteName", animTask->onCompleteName}};
}
}
void InteractiveObjectState::load(const nlohmann::json& in)
{
position.x() = in.value("positionX", position.x());
position.y() = in.value("positionY", position.y());
position.z() = in.value("positionZ", position.z());
rotationY = in.value("rotationY", rotationY);
scale = in.value("scale", scale);
alpha = in.value("alpha", alpha);
isActive = in.value("isActive", isActive);
isAnimating = in.value("isAnimating", false);
animTask.reset();
if (!isAnimating || !in.contains("animTask") ||
!in["animTask"].is_object()) {
return;
}
const auto& value = in["animTask"];
AnimTask task;
task.type = static_cast<AnimTask::Type>(value.value("type", 0));
task.startPos = {value.value("startPosX", 0.f),
value.value("startPosY", 0.f), value.value("startPosZ", 0.f)};
task.startRotY = value.value("startRotY", 0.f);
task.startScale = value.value("startScale", 1.f);
task.targetPos = {value.value("targetPosX", 0.f),
value.value("targetPosY", 0.f), value.value("targetPosZ", 0.f)};
task.targetRotY = value.value("targetRotY", 0.f);
task.targetScale = value.value("targetScale", 1.f);
task.durationMs = value.value("durationMs", 1000.f);
task.elapsedMs = value.value("elapsedMs", 0.f);
task.onCompleteName =
value.value("onCompleteName", std::string());
animTask = std::move(task);
}
} // namespace FRG
+43
View File
@@ -0,0 +1,43 @@
#include "Item.h"
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <stdexcept>
namespace FRG {
bool Inventory::addItem(const std::string& itemId)
{
itemIds.push_back(itemId);
return true;
}
bool Inventory::removeItem(const std::string& itemId)
{
auto it = std::find(itemIds.begin(), itemIds.end(), itemId);
if (it == itemIds.end()) return false;
itemIds.erase(it);
return true;
}
bool Inventory::hasItem(const std::string& itemId) const
{
return std::find(itemIds.begin(), itemIds.end(), itemId) != itemIds.end();
}
void Inventory::save(nlohmann::json& out) const
{
out["items"] = itemIds;
}
void Inventory::load(const nlohmann::json& in)
{
itemIds.clear();
if (!in.contains("items") || !in["items"].is_array()) return;
for (const auto& item : in["items"]) {
if (!item.is_string())
throw std::runtime_error("Inventory item IDs must be strings");
itemIds.push_back(item.get<std::string>());
}
}
} // namespace FRG
-39
View File
@@ -1,33 +1,8 @@
#include "Item.h"
#include "ItemRegistry.h"
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include "../utils/Utils.h"
namespace FRG {
bool Inventory::addItem(const std::string& itemId) {
itemIds.push_back(itemId);
logger() << "Item added to inventory: " << itemId << std::endl;
return true;
}
bool Inventory::removeItem(const std::string& itemId) {
auto it = std::find(itemIds.begin(), itemIds.end(), itemId);
if (it != itemIds.end()) {
logger() << "Item removed from inventory: " << itemId << std::endl;
itemIds.erase(it);
return true;
}
return false;
}
bool Inventory::hasItem(const std::string& itemId) const {
return std::find(itemIds.begin(), itemIds.end(), itemId) != itemIds.end();
}
std::vector<Item> Inventory::getItems() const {
std::vector<Item> result;
result.reserve(itemIds.size());
@@ -38,18 +13,4 @@ namespace FRG {
return result;
}
void Inventory::save(nlohmann::json& out) const {
out["items"] = itemIds;
}
void Inventory::load(const nlohmann::json& in) {
itemIds.clear();
if (!in.contains("items") || !in["items"].is_array()) return;
for (const auto& j : in["items"]) {
if (!j.is_string())
throw std::runtime_error("Inventory item IDs must be strings");
itemIds.push_back(j.get<std::string>());
}
}
} // namespace FRG
-1
View File
@@ -1,6 +1,5 @@
#pragma once
#include "render/Renderer.h"
#include <Eigen/Core>
#include <string>
#include <vector>
+78
View File
@@ -0,0 +1,78 @@
#include "Utils.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
namespace FRG {
std::string readTextFile(const std::string& filename)
{
std::ifstream file(filename, std::ios::binary);
if (!file) return {};
return std::string(
std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>());
}
std::vector<char> readFile(const std::string& filename)
{
std::ifstream file(filename, std::ios::binary | std::ios::ate);
if (!file) return {};
const std::streamsize size = file.tellg();
if (size <= 0) return {};
file.seekg(0, std::ios::beg);
std::vector<char> data(static_cast<std::size_t>(size));
if (!file.read(data.data(), size)) return {};
return data;
}
std::vector<char> readFileFromZIP(
const std::string& filename, const std::string& zipfilename)
{
if (zipfilename.empty()) return readFile(filename);
std::cerr << "ZIP resources are not enabled in the idle headless server: "
<< zipfilename << " (" << filename << ")\n";
return {};
}
bool zipFileExists(const std::string& zipfilename)
{
return false;
}
std::filesystem::path getSaveDirectory()
{
#if defined(_WIN32)
const char* base = std::getenv("APPDATA");
#else
const char* base = std::getenv("XDG_CONFIG_HOME");
if (!base || !*base) base = std::getenv("HOME");
#endif
const std::filesystem::path root =
base && *base ? std::filesystem::path(base)
: std::filesystem::current_path();
return root / "Fish Run Games" / "Shadow Over Bishkek Demo";
}
std::string readSavedTextFile(const std::string& filename)
{
return readTextFile((getSaveDirectory() / filename).string());
}
bool saveJsonToFile(
const nlohmann::json& root, const std::string& filename)
{
std::ofstream file(filename);
if (!file) return false;
file << root.dump(2);
return file.good();
}
std::ostream& logger()
{
return std::cout;
}
} // namespace FRG