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
+2111 -775

No files matched your search

+57 -31
View File
@@ -157,23 +157,30 @@ Loading and persistence restoration clear pending events and remain silent.
`InternalGameServer` is the current `IGameServer` implementation. It:
- Exclusively owns the aggregate `GameState`.
- Privately stores loaded runtime `Location` objects.
- Executes typed commands against authoritative state.
- Advances the active location simulation through `update(deltaMs)`.
- Synchronizes non-bound auxiliary state such as dialogue, Lua globals, triggers, and teleports.
- Builds and caches immutable snapshots.
- Delegates save/load serialization to the aggregate state model.
`Game` remains a privileged lifecycle host, but it no longer holds or receives a
mutable `GameState` reference. It uses private, focused internal-server operations
for campaign loading and location construction. Those operations publish revisions
themselves and are not exposed by `IGameServer`.
`LocalGameServerHost` is the graphical client's integration adapter. It owns loaded
runtime `Location` objects, forwards simulation events to them, and synchronizes
dialogue, Lua, trigger, and teleport auxiliary state. `Game` uses the explicit
`IGameServerHost` lifecycle interface and is no longer a friend of
`InternalGameServer`. The standalone server does not compile this adapter.
Location loading has two phases. `LocationContentLoader` parses renderer-free
`LocationDefinition` data, and `LocationGameState::setup()` creates/reconciles
authoritative NPC, interactive-object, navigation, taxi, trigger, teleport, and
animation state. The graphical `Location::setup()` runs afterward and only builds
presentation objects from that definition and the prepared state. Lua and dialogue
runtime behavior remains client-side.
### Animation and combat
Logical animation frame clocks live in `CharacterState`. Animation frame counts are
copied from the loaded clip metadata during runtime construction, after which state
transitions and frame-19 combat impacts are deterministic authoritative simulation.
Logical animation frame clocks live in `CharacterState`. The renderer-free content
loader reads animation frame metadata before authoritative entities are created.
Runtime `Character` construction only loads models for presentation and does not
submit metadata mutations back to the server.
`CharacterState::update()` returns value-only events containing arrival Lua function
names, attack impacts, navigation requests, and death-animation completion. `LocationGameState` applies
@@ -224,9 +231,9 @@ editor obstacles, and taxi reset. 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`.
`Character` no longer exposes gameplay mutation helpers or a generic state mutation
callback. Its only state-facing setup responsibility is reporting frame ranges from
loaded animation assets through `SetCharacterAnimationMetadataCommand`; otherwise it
focuses on model posing, rendering, audio, particles, and runtime callbacks.
callback. Character construction is presentation-only; frame ranges are already
present in `CharacterState` after content setup. It focuses on model posing,
rendering, audio, particles, and runtime callbacks.
Death-animation and health-change callbacks remain runtime-only on `Character`.
Arrival behavior is represented solely by the serialized Lua function name in
@@ -242,10 +249,10 @@ by `Game`.
`GameObjectLoader` now:
1. Loads static creation data from location configuration.
2. Creates or reuses server-owned state objects.
3. Applies saved runtime fields over the configuration defaults.
4. Constructs visual objects using stable IDs and state-backed creation data.
1. Receives immutable object/NPC definitions parsed by `LocationContentLoader`.
2. Resolves already-prepared authoritative entities by stable ID.
3. Constructs meshes, textures, VBOs, `InteractiveObject`, and `Character`
presentation wrappers without creating or overwriting authoritative state.
Configuration remains the source of visual assets, animation paths, meshes, textures, and fixed tuning data.
@@ -256,7 +263,8 @@ The normal frame flow is:
1. SDL input is processed.
2. UI code submits semantic commands through `IGameServer`.
3. The internal server freezes location character observations and calls
`CharacterState::update()` for the player and each NPC.
`CharacterState::update()` for every present player and NPC. An empty player
collection is valid.
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
@@ -283,21 +291,28 @@ When a campaign starts:
- Existing campaign state and runtime locations are cleared.
- Quest definitions are loaded.
- A campaign-level player state is created under the `"player"` ID.
- The visual player is created with its stable ID and resolves that state through snapshots.
- The graphical single-player host requests a campaign-level `"player"` state and
creates its visual wrapper.
- A standalone host may deliberately create no players; locations continue to tick.
- The battle-zone test bot is an explicit host option rather than an unconditional
campaign side effect.
### Location loading
When a location is loaded:
- A `LocationGameState` record is created or reused.
- Static configuration is loaded.
- NPC and interactive-object visuals are created against state owned by the location record.
- `LocationContentLoader` parses a renderer-free `LocationDefinition`.
- `LocationGameState::setup()` creates or reconciles navigation, taxi, NPC,
interactive-object, trigger, teleport, and animation state.
- Saved mutable fields are overlaid on configuration defaults by stable ID.
- `Location::setup()` builds static and entity visuals from the prepared definition
without mutating authoritative state.
- The shared campaign player is attached.
- Runtime visuals are created for other player IDs currently present in the
snapshot and are subsequently spawned or removed as snapshots change.
- Dialogue, Lua, navigation, trigger, teleport, and presentation callbacks are initialized.
- The runtime `Location` is registered privately with `InternalGameServer`.
- Dialogue, Lua, trigger/teleport visuals, and presentation callbacks are initialized.
- The runtime `Location` is owned and registered by `LocalGameServerHost`;
`InternalGameServer` remains headless.
### Location unloading
@@ -387,7 +402,6 @@ Still required for real multiplayer:
- Network transport and command serialization.
- Authentication and player/session assignment.
- Server tick scheduling independent of rendering.
- Snapshot delta generation or other replication strategy.
- Latency handling, interpolation, reconciliation, and prediction.
- Command validation, authorization, and anti-cheat rules.
@@ -407,6 +421,12 @@ The new state and server files are registered in:
The refactor uses portable C++17 and introduces no Windows-only gameplay code.
The standalone target lives under `server/`. It links the authoritative state,
campaign, content-definition, and portable resource sources only; SDL, OpenGL,
audio, Lua, dialogue presentation, and rendering sources are absent. It can prepare
a selected campaign/location with zero players and run the 50 ms fixed-step loop.
Networking remains outside this milestone.
The Windows Debug target was verified with:
```powershell
@@ -422,18 +442,24 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
location-local NPC ID).
- Some top-level values remain mirrors for compatibility, notably player health used by the HUD and scripts.
- Dialogue and Lua are synchronized as auxiliary location state rather than fully networked subsystems.
- `Game` uses focused privileged internal-server APIs for local content
construction, but cannot access the aggregate or its state maps directly. The
private location-construction path still supplies `LocationGameState` to the
local object loader. A remote client implementation will need a client-side
runtime-location factory driven by configuration and snapshots.
- `IGameServerLifecycle` is the graphics-free campaign/location administration
boundary. `LocalGameServerHost` adds client-only runtime construction and
auxiliary-state synchronization without making `Game` a server friend.
- Empty player maps are valid. Player-specific interactions remain inactive until
a player entity is added.
- A remote client still needs a client-side runtime-location factory driven by
configuration/definitions and replicated snapshots.
## 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/content/LocationDefinition.h` / `.cpp` — renderer-free content definitions,
parsing, and animation metadata loading.
- `src/game_server/IGameServer.h` — public snapshot and command contract.
- `src/game_server/IGameServerLifecycle.h` — graphics-free campaign/location lifecycle.
- `src/game_server/InternalGameServer.h` / `.cpp` — in-process authoritative implementation.
- `src/game_server/LocalGameServerHost.h` / `.cpp` — graphical-client runtime adapter.
- `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring.
- `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing,
interactions, collisions, scripts, and presentation-event consumption.
@@ -442,5 +468,5 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
- `src/CharacterState.h` / `src/CharacterState.cpp` — deterministic character
simulation, navigation operations, combat state, and logical animation clock.
- `src/items/InteractiveObject.h` / `.cpp` — visual object runtime bound to central object state.
- `src/items/GameObjectLoader.h` / `.cpp` — configuration/state overlay and runtime construction.
- `src/items/GameObjectLoader.h` / `.cpp` — presentation builders for prepared definitions.
- `src/MenuManager.h` / `src/MenuManager.cpp` — snapshot-driven UI and command submission.
+4
View File
@@ -106,9 +106,11 @@ add_library(main SHARED
../../../../src/GameConstants.cpp
../../../../src/GameState.h
../../../../src/GameState.cpp
../../../../src/content/LocationDefinition.cpp
../../../../src/game_server/IGameServer.h
../../../../src/game_server/InternalGameServer.h
../../../../src/game_server/InternalGameServer.cpp
../../../../src/game_server/LocalGameServerHost.cpp
../../../../src/ScriptEngine.h
../../../../src/ScriptEngine.cpp
../../../../src/navigation/PathFinder.h
@@ -117,11 +119,13 @@ add_library(main SHARED
../../../../src/items/GameObjectLoader.cpp
../../../../src/items/Item.h
../../../../src/items/Item.cpp
../../../../src/items/Inventory.cpp
../../../../src/items/ItemRegistry.h
../../../../src/items/ItemRegistry.cpp
../../../../src/Campaign.cpp
../../../../src/items/InteractiveObject.h
../../../../src/items/InteractiveObject.cpp
../../../../src/items/InteractiveObjectState.cpp
../../../../src/items/InteractiveObjectState.h
../../../../src/dialogue/DialogueTypes.h
../../../../src/dialogue/DialogueDatabase.h
+8
View File
@@ -131,9 +131,15 @@ add_executable(ShadowOverBishkekDemo
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.h
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/IGameServerLifecycle.h
../src/game_server/IGameServerHost.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.h
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -142,12 +148,14 @@ add_executable(ShadowOverBishkekDemo
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.h
../src/items/InteractiveObjectState.cpp
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
../src/dialogue/DialogueDatabase.cpp
+4
View File
@@ -125,9 +125,11 @@ set(SOURCES
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -136,11 +138,13 @@ set(SOURCES
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.cpp
../src/items/InteractiveObjectState.h
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
+8
View File
@@ -73,9 +73,15 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.h
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/IGameServerLifecycle.h
../src/game_server/IGameServerHost.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.h
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -84,12 +90,14 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.h
../src/items/InteractiveObjectState.cpp
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
../src/dialogue/DialogueDatabase.cpp
@@ -414,7 +414,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 10.0,
"approachRadius": 0.89,
"boundsMaxX": 0.01552116870880127,
@@ -439,7 +439,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.10440409183502197,
@@ -464,7 +464,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.05766594409942627,
@@ -489,7 +489,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.10906755924224854,
@@ -514,7 +514,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 15.7,
"approachRadius": 0.8,
"boundsMaxX": 0.046350955963134766,
@@ -539,7 +539,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 15.7,
"approachRadius": 0.800000011920929,
"boundsMaxX": 0.12420153617858887,
@@ -564,7 +564,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 19.95,
"approachRadius": 0.8,
"boundsMaxX": -0.050987482070922852,
@@ -589,7 +589,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 19.95,
"approachRadius": 0.8,
"boundsMaxX": 0.073349356651306152,
@@ -614,7 +614,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -19.95,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -631,7 +631,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -19.95,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -647,7 +647,7 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 6.6644,
"positionX": 6.6644004991889,
"positionY": 0.9,
"positionZ": -12.5262,
"approachRadius": 0.6,
@@ -674,7 +674,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.84296,
"positionY": 0.9,
"positionY": 0.9284919999539852,
"positionZ": -12.4661,
"approachRadius": 0.6,
"boundsMaxX": 0.17081403732299805,
@@ -726,9 +726,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.10067,
"positionY": 0.91385,
"positionZ": -11.9566,
"positionX": 5.1006695008111,
"positionY": 1.0181855017900467,
"positionZ": -11.958324999397993,
"approachRadius": 0.6,
"scale": 1.0,
"interactionRadius": 0.3,
@@ -743,7 +743,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 2.825,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -14.3,
"pivotX": -0.6,
"pivotY": 0.0,
@@ -771,7 +771,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -2.825,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -14.3,
"approachRadius": 0.8,
"boundsMaxX": 0.55439233779907227,
@@ -796,7 +796,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -15.7,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -824,7 +824,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -15.7,
"approachRadius": 0.8,
"boundsMaxX": 0.13942182064056396,
@@ -852,9 +852,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 2.8249999284744263,
"positionY": 2.8500000476837157,
"positionZ": -12.849999904632568,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -867,9 +867,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -14.25,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -882,9 +882,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": -4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -14.25,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -898,7 +898,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionY": 2.8500000476837157,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
@@ -913,8 +913,8 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionY": 2.8500000476837157,
"positionZ": 4.250000476837158,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -927,9 +927,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -19.950000762939453,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -942,9 +942,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": -4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -19.950000762939453,
"scale": 1.0,
"interactionRadius": 0.0
},
@@ -960,7 +960,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.1,
"positionY": 3.2050000190734864,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
@@ -8,9 +8,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -26.8049,
"positionY": 0.0,
"positionZ": 9.11524,
"positionX": -27.003390023612976,
"positionY": 0.32889999728649855,
"positionZ": 8.820399544944763,
"approachRadius": 1.5,
"boundsMaxX": 2.9518299102783203,
"boundsMaxY": 0.5,
@@ -33,9 +33,9 @@
"rotationX": 0.0,
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": 0.55,
"positionZ": -14.4195,
"positionX": -10.503284412622452,
"positionY": 0.9012498408555984,
"positionZ": -14.861633367061614,
"approachRadius": 0.5,
"boundsMaxX": 0.8858480453491211,
"boundsMaxY": 0.7,
@@ -59,8 +59,8 @@
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": 0.0,
"positionZ": -14.4195,
"positionY": 0.3359999924898147,
"positionZ": -14.839499933242797,
"approachRadius": 0.5,
"boundsMaxX": 0,
"boundsMaxY": 0.0,
@@ -116,9 +116,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0,
"positionY": -5.0,
"positionZ": 0,
"positionX": -5.365539073944092,
"positionY": 0.9888315200805664,
"positionZ": 2.468366503715515,
"approachRadius": 0.6,
"boundsMaxX": 0.19605731964111328,
"boundsMaxY": 0.4,
@@ -144,9 +144,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.0916,
"positionY": 1.1534,
"positionZ": 0.751629,
"positionX": 5.01753252658844,
"positionY": 1.353411484324932,
"positionZ": 0.6829135021486282,
"approachRadius": 1.2,
"boundsMaxX": 0.6253643035888672,
"boundsMaxY": 0.5,
@@ -168,9 +168,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -4.8327,
"positionX": -4.959765496635623,
"positionY": 0.93551,
"positionZ": 2.2418,
"positionZ": 2.369899506953731,
"approachRadius": 1.4,
"boundsMaxX": 0.30867958068847656,
"boundsMaxY": 0.4,
@@ -192,7 +192,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.84795,
"positionY": 0.6595,
"positionY": 0.6809705001562834,
"positionZ": 2.90552,
"approachRadius": 0.6,
"boundsMaxX": 0.2350165843963623,
@@ -294,9 +294,9 @@
"rotationX": 0.0,
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": -5.0,
"positionZ": -14.4195,
"positionX": -10.503284412622452,
"positionY": -4.648750159144401,
"positionZ": -14.861633367061614,
"approachRadius": 0.6,
"boundsMaxX": 0.0,
"boundsMaxY": 0.0,
@@ -319,8 +319,8 @@
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": -5.0,
"positionZ": -14.4195,
"positionY": -4.664000007510185,
"positionZ": -14.839499933242797,
"approachRadius": 0.6,
"boundsMaxX": 0.0,
"boundsMaxY": 0.0,
@@ -344,7 +344,7 @@
"rotationZ": 0.0,
"positionX": 1.5,
"positionY": 0.975,
"positionZ": 6.965,
"positionZ": 6.400000002384186,
"approachRadius": 0.8,
"boundsMaxX": 0.06785714626312256,
"boundsMaxY": 1.0,
@@ -372,7 +372,7 @@
"rotationZ": 0.0,
"positionX": 1.5,
"positionY": 0.975,
"positionZ": -1.035,
"positionZ": -1.5999999976158141,
"approachRadius": 0.8,
"boundsMaxX": 0.10772454738616943,
"boundsMaxY": 1.0,
@@ -400,7 +400,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": -1.035,
"positionZ": -1.5999999976158141,
"approachRadius": 0.8,
"boundsMaxX": 0.12115073204040527,
"boundsMaxY": 1.0,
@@ -428,7 +428,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": 6.965,
"positionZ": 6.400000002384186,
"approachRadius": 0.8,
"boundsMaxX": 0.09747374057769775,
"boundsMaxY": 1.0,
@@ -454,9 +454,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.565,
"positionX": 0.0,
"positionY": 0.975,
"positionZ": 8,
"positionZ": 8.000001000240445,
"approachRadius": 0.9,
"boundsMaxX": 0.6243956089019775,
"boundsMaxY": 1.0,
@@ -484,7 +484,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": -9.035,
"positionZ": -9.599999020778657,
"approachRadius": 0.8,
"boundsMaxX": 0.10914039611816406,
"boundsMaxY": 1.0,
@@ -511,9 +511,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 2.2500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -3.999999761581421,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -530,8 +530,8 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionY": 2.4499998092651367,
"positionZ": 12.0,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -547,9 +547,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -12.0,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -565,9 +565,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -583,9 +583,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -601,9 +601,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": 4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -619,9 +619,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": 4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
+39 -2
View File
@@ -77,7 +77,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 3.1999998092651367,
"positionY": 3.200000047683716,
"positionZ": 0.0,
"rotationY": 0.0,
"scale": 1.0
@@ -148,6 +148,43 @@
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"battle_zone_test_bot": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [],
"position": [
0.0,
0.0,
0.0
],
"requestedWalkTarget": [
0.0,
0.0,
0.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
0.0,
0.0,
0.0
]
},
"player": {
"animationFrame": 0.0,
"attackTargetId": "",
@@ -195,7 +232,7 @@
"questJournal": {
"quests": []
},
"savedAt": "2026-10-28 23:24",
"savedAt": "2026-10-30 14:50",
"schemaVersion": 1,
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
@@ -159,6 +159,7 @@
"animTask": {
"durationMs": 10.0,
"elapsedMs": 0.0,
"onCompleteName": "",
"startPosX": -1.100000023841858,
"startPosY": 0.9695000052452087,
"startPosZ": -19.950000762939453,
@@ -184,6 +185,7 @@
"animTask": {
"durationMs": 10.0,
"elapsedMs": 0.0,
"onCompleteName": "",
"startPosX": 1.100000023841858,
"startPosY": 0.9695000052452087,
"startPosZ": -19.950000762939453,
@@ -259,7 +261,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 5.842959880828857,
"positionY": 0.9284919500350952,
"positionY": 0.92849200963974,
"positionZ": -12.466099739074707,
"rotationY": 0.0,
"scale": 1.0
@@ -763,7 +765,7 @@
}
]
},
"savedAt": "2026-10-28 23:16",
"savedAt": "2026-10-30 14:48",
"schemaVersion": 1,
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
+8 -8
View File
@@ -9,7 +9,7 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 0.6,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
@@ -23,7 +23,7 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 1.4,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
@@ -37,7 +37,7 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 2.2,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
@@ -51,7 +51,7 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 3.0,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
@@ -65,7 +65,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.0,
"positionY": 3.2,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
@@ -79,7 +79,7 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": -1.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
@@ -93,7 +93,7 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
@@ -107,7 +107,7 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": 1.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
+129
View File
@@ -0,0 +1,129 @@
cmake_minimum_required(VERSION 3.16)
project(GameServer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..")
find_package(Threads REQUIRED)
find_package(Eigen3 3.3 CONFIG QUIET)
if(NOT TARGET Eigen3::Eigen)
set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0")
if(NOT EXISTS "${EIGEN_BUNDLED_DIR}/Eigen/Core")
message(FATAL_ERROR
"Eigen3 was not found. Install Eigen3 or configure the graphical "
"project once to populate thirdparty/eigen-5.0.0.")
endif()
add_library(game_server_eigen INTERFACE)
target_include_directories(game_server_eigen INTERFACE "${EIGEN_BUNDLED_DIR}")
add_library(Eigen3::Eigen ALIAS game_server_eigen)
endif()
add_library(game_server_core STATIC
"${GAME_ROOT}/src/Campaign.cpp"
"${GAME_ROOT}/src/Campaign.h"
"${GAME_ROOT}/src/CharacterState.cpp"
"${GAME_ROOT}/src/CharacterState.h"
"${GAME_ROOT}/src/GameState.cpp"
"${GAME_ROOT}/src/GameState.h"
"${GAME_ROOT}/src/ISaveable.h"
"${GAME_ROOT}/src/Localization.cpp"
"${GAME_ROOT}/src/Localization.h"
"${GAME_ROOT}/src/LocationGameState.cpp"
"${GAME_ROOT}/src/LocationGameState.h"
"${GAME_ROOT}/src/LocationState.cpp"
"${GAME_ROOT}/src/LocationState.h"
"${GAME_ROOT}/src/NpcCar.cpp"
"${GAME_ROOT}/src/NpcCar.h"
"${GAME_ROOT}/src/content/LocationDefinition.cpp"
"${GAME_ROOT}/src/content/LocationDefinition.h"
"${GAME_ROOT}/src/game_server/IGameServer.h"
"${GAME_ROOT}/src/game_server/IGameServerLifecycle.h"
"${GAME_ROOT}/src/game_server/InternalGameServer.cpp"
"${GAME_ROOT}/src/game_server/InternalGameServer.h"
"${GAME_ROOT}/src/items/InteractiveObjectState.cpp"
"${GAME_ROOT}/src/items/InteractiveObjectState.h"
"${GAME_ROOT}/src/items/Inventory.cpp"
"${GAME_ROOT}/src/items/Item.h"
"${GAME_ROOT}/src/navigation/PathFinder.cpp"
"${GAME_ROOT}/src/navigation/PathFinder.h"
"${GAME_ROOT}/src/quest/QuestJournal.cpp"
"${GAME_ROOT}/src/quest/QuestJournal.h"
"${GAME_ROOT}/src/quest/QuestTypes.h"
"${GAME_ROOT}/src/utils/HeadlessUtils.cpp"
"${GAME_ROOT}/src/utils/Utils.h"
)
source_group(TREE "${GAME_ROOT}/src" PREFIX "Source" FILES
"${GAME_ROOT}/src/Campaign.cpp"
"${GAME_ROOT}/src/Campaign.h"
"${GAME_ROOT}/src/CharacterState.cpp"
"${GAME_ROOT}/src/CharacterState.h"
"${GAME_ROOT}/src/GameState.cpp"
"${GAME_ROOT}/src/GameState.h"
"${GAME_ROOT}/src/ISaveable.h"
"${GAME_ROOT}/src/Localization.cpp"
"${GAME_ROOT}/src/Localization.h"
"${GAME_ROOT}/src/LocationGameState.cpp"
"${GAME_ROOT}/src/LocationGameState.h"
"${GAME_ROOT}/src/LocationState.cpp"
"${GAME_ROOT}/src/LocationState.h"
"${GAME_ROOT}/src/NpcCar.cpp"
"${GAME_ROOT}/src/NpcCar.h"
"${GAME_ROOT}/src/content/LocationDefinition.cpp"
"${GAME_ROOT}/src/content/LocationDefinition.h"
"${GAME_ROOT}/src/game_server/IGameServer.h"
"${GAME_ROOT}/src/game_server/IGameServerLifecycle.h"
"${GAME_ROOT}/src/game_server/InternalGameServer.cpp"
"${GAME_ROOT}/src/game_server/InternalGameServer.h"
"${GAME_ROOT}/src/items/InteractiveObjectState.cpp"
"${GAME_ROOT}/src/items/InteractiveObjectState.h"
"${GAME_ROOT}/src/items/Inventory.cpp"
"${GAME_ROOT}/src/items/Item.h"
"${GAME_ROOT}/src/navigation/PathFinder.cpp"
"${GAME_ROOT}/src/navigation/PathFinder.h"
"${GAME_ROOT}/src/quest/QuestJournal.cpp"
"${GAME_ROOT}/src/quest/QuestJournal.h"
"${GAME_ROOT}/src/quest/QuestTypes.h"
"${GAME_ROOT}/src/utils/HeadlessUtils.cpp"
"${GAME_ROOT}/src/utils/Utils.h"
)
target_include_directories(game_server_core PUBLIC "${GAME_ROOT}/src")
target_link_libraries(game_server_core PUBLIC Eigen3::Eigen Threads::Threads)
target_compile_definitions(game_server_core PRIVATE FRG_HEADLESS_SERVER=1)
if(MSVC)
target_compile_options(game_server_core PRIVATE /W4 /permissive-)
else()
target_compile_options(game_server_core PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(GameServer src/main.cpp README.md)
source_group("Server" FILES src/main.cpp README.md)
target_link_libraries(GameServer PRIVATE game_server_core)
include(CTest)
if(BUILD_TESTING)
add_executable(
GameServerStateTests tests/LocationGameStateTests.cpp)
target_link_libraries(GameServerStateTests PRIVATE game_server_core)
source_group(
"Server\\Tests" FILES tests/LocationGameStateTests.cpp)
add_test(
NAME GameServer.LocationState
COMMAND GameServerStateTests)
add_test(
NAME GameServer.IdleSmoke
COMMAND GameServer --ticks 2 --no-sleep)
add_test(
NAME GameServer.BattleZoneZeroPlayers
COMMAND GameServer --campaign battle_zone --location uni_exterior
--ticks 2 --no-sleep)
set_tests_properties(
GameServer.BattleZoneZeroPlayers
PROPERTIES WORKING_DIRECTORY "${GAME_ROOT}")
endif()
+77
View File
@@ -0,0 +1,77 @@
# Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. It can prepare campaign/location data
headlessly; networking is not implemented yet.
## Build
Windows:
```powershell
cmake -S server -B server/build
cmake --build server/build --config Release --target GameServer
ctest --test-dir server/build -C Release --output-on-failure
```
Linux:
```bash
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
cmake --build server/build --target GameServer --parallel
ctest --test-dir server/build --output-on-failure
```
Eigen3 must either be installed or be available at
`thirdparty/eigen-5.0.0`. The headless target reads ordinary filesystem resources
only; ZIP-backed campaign loading belongs to the later content-loading
milestone.
Run until interrupted:
```bash
./server/build/GameServer
```
For a multi-configuration Windows build, the executable is normally
`server/build/Release/GameServer.exe`.
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay.
Load a location with no players:
```bash
./server/build/GameServer --campaign battle_zone --location uni_exterior
```
If `--location` is omitted, the campaign's configured start location is used.
`--campaigns PATH` selects a different campaign registry. Content paths are
resolved from the process working directory, so run from the repository root
when using the bundled `resources/` tree.
Available startup options:
```text
--campaign ID
--location ID
--campaigns PATH
--tick-ms N
--ticks N
--no-sleep
```
The graphical client executes Lua/dialogue behavior after building its runtime
`Location`; the headless content pass loads authoritative navigation, NPC,
interactive-object, trigger, teleport, taxi, and animation metadata only.
The standalone lifecycle intentionally creates neither the local `"player"` nor
the battle-zone test bot. Those are opt-ins used by the graphical host.
## Tests
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`.
+123
View File
@@ -0,0 +1,123 @@
#include "game_server/InternalGameServer.h"
#include "Campaign.h"
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <exception>
#include <iostream>
#include <limits>
#include <string>
#include <thread>
namespace {
std::atomic_bool stopRequested{false};
void requestStop(int)
{
stopRequested.store(true);
}
std::int64_t parsePositiveNumber(const char* value, const char* option)
{
try {
const std::string text(value);
std::size_t consumed = 0;
const long long parsed = std::stoll(text, &consumed);
if (consumed != text.size() || parsed <= 0)
throw std::invalid_argument("not positive");
return static_cast<std::int64_t>(parsed);
} catch (const std::exception&) {
throw std::runtime_error(
std::string("Invalid value for ") + option + ": " + value);
}
}
} // namespace
int main(int argc, char** argv)
{
std::int64_t tickMs = 50;
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
bool sleepBetweenTicks = true;
std::string campaignId;
std::string locationId;
std::string campaignsPath = "resources/config/campaigns.json";
try {
for (int index = 1; index < argc; ++index) {
const std::string option(argv[index]);
if (option == "--ticks" && index + 1 < argc) {
maxTicks = parsePositiveNumber(argv[++index], "--ticks");
} else if (option == "--tick-ms" && index + 1 < argc) {
tickMs = parsePositiveNumber(argv[++index], "--tick-ms");
} else if (option == "--no-sleep") {
sleepBetweenTicks = false;
} else if (option == "--campaign" && index + 1 < argc) {
campaignId = argv[++index];
} else if (option == "--location" && index + 1 < argc) {
locationId = argv[++index];
} else if (option == "--campaigns" && index + 1 < argc) {
campaignsPath = argv[++index];
} else if (option == "--help") {
std::cout
<< "Usage: GameServer [--ticks N] [--tick-ms N] "
"[--no-sleep] [--campaign ID] [--location ID] "
"[--campaigns PATH]\n";
return EXIT_SUCCESS;
} else {
throw std::runtime_error("Unknown or incomplete option: " + option);
}
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return EXIT_FAILURE;
}
std::signal(SIGINT, requestStop);
std::signal(SIGTERM, requestStop);
FRG::InternalGameServer server;
if (!campaignId.empty()) {
try {
FRG::CampaignRegistry::instance().loadFromJson(campaignsPath);
const FRG::CampaignConfig* campaign =
FRG::CampaignRegistry::instance().find(campaignId);
if (!campaign)
throw std::runtime_error(
"Unknown campaign '" + campaignId + "'");
const std::string selectedLocation = locationId.empty()
? campaign->startLocation : locationId;
const FRG::LocationConfig* location =
campaign->findLocation(selectedLocation);
if (!location)
throw std::runtime_error(
"Campaign '" + campaignId +
"' has no location '" + selectedLocation + "'");
server.startCampaign(*campaign, std::string(), false, false);
server.prepareLocation(*location);
server.activateLocation(selectedLocation);
std::cout << "Loaded campaign '" << campaignId
<< "', location '" << selectedLocation
<< "' with zero players\n";
} catch (const std::exception& error) {
std::cerr << "Server content setup failed: "
<< error.what() << '\n';
return EXIT_FAILURE;
}
}
const auto tickDuration = std::chrono::milliseconds(tickMs);
auto nextTick = std::chrono::steady_clock::now();
std::cout << "GameServer started (idle headless mode, "
<< tickMs << " ms tick)\n";
for (std::int64_t tick = 0;
tick < maxTicks && !stopRequested.load(); ++tick) {
nextTick += tickDuration;
server.update(tickMs);
if (sleepBetweenTicks) std::this_thread::sleep_until(nextTick);
}
std::cout << "GameServer stopped\n";
return EXIT_SUCCESS;
}
+67
View File
@@ -0,0 +1,67 @@
#include "LocationGameState.h"
#include <cstdlib>
#include <iostream>
namespace {
int fail(const char* message)
{
std::cerr << message << '\n';
return EXIT_FAILURE;
}
} // namespace
int main()
{
FRG::LocationDefinition definition;
definition.config.id = "test_location";
FRG::NpcData npc;
npc.id = "npc";
npc.name = "Test NPC";
npc.hp = 75.f;
definition.npcs.push_back(npc);
FRG::InteractiveObjectData object;
object.id = "object";
object.base.name = "Test Object";
object.base.meshPath = "unused.mesh";
object.isActive = true;
definition.interactiveObjects.push_back(object);
FRG::TriggerZoneDefinition trigger;
trigger.id = "trigger";
trigger.enabled = true;
definition.triggerZones.push_back(trigger);
FRG::TeleportDefinition teleport;
teleport.id = "teleport";
teleport.active = false;
definition.teleports.push_back(teleport);
FRG::LocationGameState state;
if (!state.setup(definition, {}))
return fail("First setup must initialize runtime fields");
if (state.npcs.size() != 1 || state.interactiveObjects.size() != 1)
return fail("Definitions were not applied");
state.npcs.at("npc")->hp = 12.f;
state.interactiveObjects.at("object")->isActive = false;
state.triggerZones.at("trigger").enabled = false;
state.teleportZones.at("teleport") = true;
state.hasPersistentState = true;
if (state.setup(definition, {}))
return fail("Persistent setup must not reinitialize runtime fields");
if (state.npcs.at("npc")->hp != 12.f ||
state.interactiveObjects.at("object")->isActive ||
state.triggerZones.at("trigger").enabled ||
!state.teleportZones.at("teleport")) {
return fail("Repeated setup did not preserve runtime state");
}
FRG::PlayerStateMap noPlayers;
state.update(50, noPlayers, false);
return EXIT_SUCCESS;
}
+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
+63 -117
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;
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