diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index c56a724..16a0b05 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -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. diff --git a/proj-android/app/jni/src/CMakeLists.txt b/proj-android/app/jni/src/CMakeLists.txt index 18dc632..140064c 100644 --- a/proj-android/app/jni/src/CMakeLists.txt +++ b/proj-android/app/jni/src/CMakeLists.txt @@ -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 diff --git a/proj-linux/CMakeLists.txt b/proj-linux/CMakeLists.txt index b55ea07..fb8994f 100644 --- a/proj-linux/CMakeLists.txt +++ b/proj-linux/CMakeLists.txt @@ -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 diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index 8c02ea1..c2c7116 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -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 diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 6b918dd..78b33ed 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -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 diff --git a/resources/config/interactive_objects_dorm_x.json b/resources/config/interactive_objects_dorm_x.json index ebf6741..1066d89 100644 --- a/resources/config/interactive_objects_dorm_x.json +++ b/resources/config/interactive_objects_dorm_x.json @@ -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 diff --git a/resources/config/interactive_objects_uni_exterior_x.json b/resources/config/interactive_objects_uni_exterior_x.json index 0cd755c..ed27051 100644 --- a/resources/config/interactive_objects_uni_exterior_x.json +++ b/resources/config/interactive_objects_uni_exterior_x.json @@ -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, diff --git a/resources/config/interactive_objects_uni_interior_x.json b/resources/config/interactive_objects_uni_interior_x.json index 6c95e9b..7bbdc0e 100644 --- a/resources/config/interactive_objects_uni_interior_x.json +++ b/resources/config/interactive_objects_uni_interior_x.json @@ -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, diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json index 3219ec4..46f49cb 100644 --- a/resources/config/start_state_battle_zone.json +++ b/resources/config/start_state_battle_zone.json @@ -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, diff --git a/resources/config/start_state.json b/resources/start_state.json similarity index 99% rename from resources/config/start_state.json rename to resources/start_state.json index ffff7e0..f00bbc2 100644 --- a/resources/config/start_state.json +++ b/resources/start_state.json @@ -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, diff --git a/resources/w/shelter/interactive_objects.json b/resources/w/shelter/interactive_objects.json index 01a7aad..8419bfe 100644 --- a/resources/w/shelter/interactive_objects.json +++ b/resources/w/shelter/interactive_objects.json @@ -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 diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..cb6ad49 --- /dev/null +++ b/server/CMakeLists.txt @@ -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() diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..a90fe74 --- /dev/null +++ b/server/README.md @@ -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`. diff --git a/server/src/main.cpp b/server/src/main.cpp new file mode 100644 index 0000000..d7278ad --- /dev/null +++ b/server/src/main.cpp @@ -0,0 +1,123 @@ +#include "game_server/InternalGameServer.h" +#include "Campaign.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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::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; +} diff --git a/server/tests/LocationGameStateTests.cpp b/server/tests/LocationGameStateTests.cpp new file mode 100644 index 0000000..05cece4 --- /dev/null +++ b/server/tests/LocationGameStateTests.cpp @@ -0,0 +1,67 @@ +#include "LocationGameState.h" +#include +#include + +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; +} diff --git a/src/Campaign.cpp b/src/Campaign.cpp index 811a564..5531160 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -3,6 +3,7 @@ #include "external/nlohmann/json.hpp" #include + namespace FRG { using json = nlohmann::json; diff --git a/src/Character.cpp b/src/Character.cpp index bd255ca..233c23c 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -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::createFromState(const CharacterState& st, IGameServer& server, diff --git a/src/Character.h b/src/Character.h index eb425c5..2e76055 100644 --- a/src/Character.h +++ b/src/Character.h @@ -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_; diff --git a/src/Game.cpp b/src/Game.cpp index e065368..345baf4 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -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()) , 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 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 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( 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); }); diff --git a/src/Game.h b/src/Game.h index b963ac1..aff7c86 100644 --- a/src/Game.h +++ b/src/Game.h @@ -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; } diff --git a/src/GameState.cpp b/src/GameState.cpp index dca64a1..bb50320 100644 --- a/src/GameState.cpp +++ b/src/GameState.cpp @@ -1,4 +1,5 @@ #include "GameState.h" +#include "utils/Utils.h" #include #include #include @@ -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(); @@ -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 '" + diff --git a/src/Location.cpp b/src/Location.cpp index 8a73d02..a25ad6d 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -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, [¶ms](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(); diff --git a/src/Location.h b/src/Location.h index f3185a1..37e537d 100644 --- a/src/Location.h +++ b/src/Location.h @@ -53,6 +53,7 @@ namespace FRG struct LocationSetup { + std::shared_ptr 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; diff --git a/src/LocationGameState.cpp b/src/LocationGameState.cpp index decb251..35a8156 100644 --- a/src/LocationGameState.cpp +++ b/src/LocationGameState.cpp @@ -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(); + 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 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(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 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(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 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 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(std::move(content)); + return initializeRuntimeFields; +} + CharacterState* LocationGameState::findCharacter( const std::string& id, PlayerStateMap& players) { diff --git a/src/LocationGameState.h b/src/LocationGameState.h index d74fa69..e64e54b 100644 --- a/src/LocationGameState.h +++ b/src/LocationGameState.h @@ -3,6 +3,7 @@ #include "CharacterState.h" #include "LocationState.h" #include "items/InteractiveObjectState.h" +#include "content/LocationDefinition.h" #include "external/nlohmann/json.hpp" #include #include @@ -57,7 +58,9 @@ struct LocationGameState { std::unordered_map teleportZones; nlohmann::json dialogue = nlohmann::json::object(); nlohmann::json scriptGlobals = nlohmann::json::object(); + std::shared_ptr 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; diff --git a/src/LocationState.cpp b/src/LocationState.cpp index 3cd4b61..dda79a7 100644 --- a/src/LocationState.cpp +++ b/src/LocationState.cpp @@ -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& paths) + void LocationState::initNavigation( + const std::vector& 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 = diff --git a/src/LocationState.h b/src/LocationState.h index e9d7590..fb529af 100644 --- a/src/LocationState.h +++ b/src/LocationState.h @@ -12,7 +12,9 @@ struct LocationState : public ISaveable { //It might be changed only in the editor mode during development. std::vector navigationMaps; std::vector planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const; - void initNavigation(const std::vector& paths); + void initNavigation( + const std::vector& 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 taxiCarWaypoints; + Eigen::Vector3f taxiCarTriggerPosition = Eigen::Vector3f(0,0,0); + std::vector taxiCarWaypoints; // (trigger/teleport zone runtime flags are serialised by id directly in Location::save/load) diff --git a/src/NpcCar.h b/src/NpcCar.h index 3375e3f..c9ecb05 100644 --- a/src/NpcCar.h +++ b/src/NpcCar.h @@ -1,6 +1,7 @@ #pragma once -#include "render/Renderer.h" -#include "Environment.h" +#include +#include +#include namespace FRG { struct NpcCar diff --git a/src/content/LocationDefinition.cpp b/src/content/LocationDefinition.cpp new file mode 100644 index 0000000..a26e3c6 --- /dev/null +++ b/src/content/LocationDefinition.cpp @@ -0,0 +1,427 @@ +#include "LocationDefinition.h" +#include "../external/nlohmann/json.hpp" +#include "../utils/Utils.h" +#include +#include +#include +#include +#include + +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 readBinaryAnimationMetadata( + const std::string& path, const std::string& zipFile) +{ + const std::vector 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(end - cursor)) + throw std::runtime_error("Unexpected EOF in " + path); + cursor += size; + }; + auto readU32 = [&]() { + std::uint32_t value = 0; + if (sizeof(value) > static_cast(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(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(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(vertexCount) * 3 * sizeof(float)); + const std::uint32_t faceCount = readU32(); + skip(static_cast(faceCount) * 3 * 2 * sizeof(float)); + skip(static_cast(vertexCount) * 3 * sizeof(float)); + const std::uint32_t triangleCount = readU32(); + skip(static_cast(triangleCount) * 3 * sizeof(std::int32_t)); + for (std::uint32_t vertex = 0; vertex < vertexCount; ++vertex) + skip(static_cast(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(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 ids; + for (const auto& item : root.value("objects", json::array())) { + InteractiveObjectData data; + data.id = item.at("id").get(); + 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 ids; + for (const auto& item : root.value("npcs", json::array())) { + NpcData data; + data.id = item.at("id").get(); + 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(); + } + } + 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 ids; + for (const auto& item : root.value("teleports", json::array())) { + TeleportDefinition data; + data.id = item.at("id").get(); + 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 ids; + for (const auto& item : + root.value("trigger_zones", json::array())) { + TriggerZoneDefinition data; + data.id = item.at("id").get(); + 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 diff --git a/src/content/LocationDefinition.h b/src/content/LocationDefinition.h new file mode 100644 index 0000000..d8ebc87 --- /dev/null +++ b/src/content/LocationDefinition.h @@ -0,0 +1,132 @@ +#pragma once + +#include "../Campaign.h" +#include "../CharacterState.h" +#include "../items/InteractiveObjectState.h" +#include +#include +#include +#include + +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 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 staticObjects; + std::vector interactiveObjects; + std::vector npcs; + std::vector teleports; + std::vector triggerZones; + std::vector pointLights; +}; + +class LocationContentLoader { +public: + static LocationDefinition load( + const LocationConfig& config, const std::string& zipFile = {}); +}; + +void loadCharacterAnimationMetadata( + CharacterState& state, const std::string& zipFile = {}); + +} // namespace FRG diff --git a/src/game_server/IGameServerHost.h b/src/game_server/IGameServerHost.h new file mode 100644 index 0000000..f11c126 --- /dev/null +++ b/src/game_server/IGameServerHost.h @@ -0,0 +1,45 @@ +#pragma once + +#include "IGameServer.h" +#include +#include +#include + +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, + 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 loadedRuntimeLocationIds() const = 0; + virtual void clearRuntimeLocations(bool retainState) = 0; + virtual void bindRuntimeLocation( + const std::string& id, std::shared_ptr 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 diff --git a/src/game_server/IGameServerLifecycle.h b/src/game_server/IGameServerLifecycle.h new file mode 100644 index 0000000..0c77cdc --- /dev/null +++ b/src/game_server/IGameServerLifecycle.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +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 diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp index 9bfa453..4fe9e0b 100644 --- a/src/game_server/InternalGameServer.cpp +++ b/src/game_server/InternalGameServer.cpp @@ -1,6 +1,6 @@ #include "InternalGameServer.h" #include "../Campaign.h" -#include "../Location.h" +#include "../content/LocationDefinition.h" #include #include #include @@ -8,8 +8,6 @@ namespace FRG { namespace { -// Set this to false to disable the battle-zone remote-player test bot. -constexpr bool kEnableBattleZoneTestBot = true; constexpr const char* kBattleZoneCampaignId = "battle_zone"; constexpr const char* kBattleZoneTestBotId = "battle_zone_test_bot"; constexpr std::int64_t kBattleZoneTestBotIntervalMs = 5000; @@ -20,7 +18,7 @@ InternalGameServer::InternalGameServer() = default; void InternalGameServer::ensureBattleZoneTestBot() { if (state_.campaignId != kBattleZoneCampaignId) return; - if (!kEnableBattleZoneTestBot) { + if (!battleZoneTestBotEnabled_) { state_.players.erase(kBattleZoneTestBotId); return; } @@ -33,7 +31,7 @@ void InternalGameServer::ensureBattleZoneTestBot() void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs) { - if (!kEnableBattleZoneTestBot || + if (!battleZoneTestBotEnabled_ || state_.campaignId != kBattleZoneCampaignId || state_.currentLocationName.empty() || state_.players.find(kBattleZoneTestBotId) == state_.players.end()) { @@ -57,24 +55,66 @@ void InternalGameServer::initializeCampaign( const std::string& campaignId, const std::string& questsPath, const std::string& zipFile) { - state_.campaignId = campaignId; + CampaignConfig campaign; + campaign.id = campaignId; + campaign.questsPath = questsPath; + startCampaign(campaign, zipFile, true, true); +} + +void InternalGameServer::startCampaign( + const CampaignConfig& campaign, const std::string& zipFile, + bool createLocalPlayer, bool enableTestBot) +{ + resourceZipFile_ = zipFile; + battleZoneTestBotEnabled_ = enableTestBot; + state_.campaignId = campaign.id; state_.currentLocationName.clear(); state_.locations.clear(); state_.players.clear(); - state_.questJournal.loadFromFile(questsPath, zipFile); + state_.questJournal.loadFromFile(campaign.questsPath, zipFile); state_.globalFloats["player_hp"] = 200.f; - auto player = std::make_shared(); - player->configureAsPlayer(); - state_.players.emplace(localPlayerId_, std::move(player)); + if (createLocalPlayer) { + auto player = std::make_shared(); + 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(); - 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, - 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 InternalGameServer::loadedRuntimeLocationIds() const -{ - std::vector 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) { state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile); @@ -494,26 +475,22 @@ std::vector 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(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) -{ - 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_; diff --git a/src/game_server/InternalGameServer.h b/src/game_server/InternalGameServer.h index 2e55ac0..ecd3c8e 100644 --- a/src/game_server/InternalGameServer.h +++ b/src/game_server/InternalGameServer.h @@ -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, - LocationSetup params, AudioPlayerAsync* audioPlayer); - Location* runtimeLocation(const std::string& id) const; - bool hasRuntimeLocation(const std::string& id) const; - std::vector loadedRuntimeLocationIds() const; - void clearRuntimeLocations(bool retainState); - void bindRuntimeLocation(const std::string& id, std::shared_ptr 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> runtimeLocations_; + bool battleZoneTestBotEnabled_ = false; + std::string pendingSimulationLocationId_; + LocationSimulationEvents pendingSimulationEvents_; + std::vector> pendingTriggerZoneUpdates_; std::uint64_t revision_ = 1; mutable std::uint64_t cachedRevision_ = 0; mutable std::shared_ptr cachedSnapshot_; diff --git a/src/game_server/LocalGameServerHost.cpp b/src/game_server/LocalGameServerHost.cpp new file mode 100644 index 0000000..11220f9 --- /dev/null +++ b/src/game_server/LocalGameServerHost.cpp @@ -0,0 +1,152 @@ +#include "LocalGameServerHost.h" +#include "InternalGameServer.h" +#include "../Campaign.h" +#include "../Location.h" +#include + +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, + 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 LocalGameServerHost::loadedRuntimeLocationIds() const +{ + std::vector 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) +{ + 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 diff --git a/src/game_server/LocalGameServerHost.h b/src/game_server/LocalGameServerHost.h new file mode 100644 index 0000000..7e5a5f7 --- /dev/null +++ b/src/game_server/LocalGameServerHost.h @@ -0,0 +1,45 @@ +#pragma once + +#include "IGameServerHost.h" +#include + +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, + LocationSetup params, AudioPlayerAsync* audioPlayer) override; + Location* runtimeLocation(const std::string& id) const override; + bool hasRuntimeLocation(const std::string& id) const override; + std::vector loadedRuntimeLocationIds() const override; + void clearRuntimeLocations(bool retainState) override; + void bindRuntimeLocation( + const std::string& id, std::shared_ptr 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> runtimeLocations_; +}; + +} // namespace FRG diff --git a/src/items/GameObjectLoader.cpp b/src/items/GameObjectLoader.cpp index 72257da..5624c28 100644 --- a/src/items/GameObjectLoader.cpp +++ b/src/items/GameObjectLoader.cpp @@ -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& definitions, Renderer& renderer, const std::string& zipPath) { StaticObjectSet result; - std::vector allData = loadFromJson(jsonPath, zipPath); - // Group objects sharing mesh + textures (in file order) so they can be drawn instanced. std::vector> groups; std::unordered_map 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 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 GameObjectLoader::createInteractiveObjects( + const std::vector& definitions, Renderer& renderer, std::unordered_map>& states, const std::string& zipPath) { std::vector interactiveObjects; - - if (jsonPath.empty()) - return interactiveObjects; - - const auto definitions = loadInteractiveFromJson(jsonPath, zipPath); - std::unordered_set 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(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 + GameObjectLoader::loadAndCreateInteractiveObjects( + const std::string& jsonPath, Renderer& renderer, + std::unordered_map>& states, + const std::string& zipPath) + { + return createInteractiveObjects( + loadInteractiveFromJson(jsonPath, zipPath), renderer, states, + zipPath); + } + std::vector GameObjectLoader::loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath) { std::vector 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(meshTextures.begin(), meshTextures.end()); - info.singleTexturePath = texturePath; - - return st; - } - - std::vector> GameObjectLoader::loadAndCreate_Npcs( - const std::string& jsonPath, + std::vector> GameObjectLoader::createNpcs( + const std::vector& definitions, Renderer& renderer, - std::unordered_map>& states, + const std::unordered_map>& states, IGameServer& server, const std::string& locationId, const std::string& zipPath) { std::vector> npcs; - const auto definitions = loadNpcsFromJson(jsonPath, zipPath); - std::unordered_set 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(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> + GameObjectLoader::loadAndCreate_Npcs( + const std::string& jsonPath, Renderer& renderer, + std::unordered_map>& states, + IGameServer& server, const std::string& locationId, + const std::string& zipPath) + { + return createNpcs( + loadNpcsFromJson(jsonPath, zipPath), renderer, states, server, + locationId, zipPath); + } + } // namespace FRG diff --git a/src/items/GameObjectLoader.h b/src/items/GameObjectLoader.h index ea563c3..825a5a8 100644 --- a/src/items/GameObjectLoader.h +++ b/src/items/GameObjectLoader.h @@ -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 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 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& definitions, + Renderer& renderer, const std::string& zipPath = ""); + static std::vector createInteractiveObjects( + const std::vector& definitions, + Renderer& renderer, + std::unordered_map>& states, + const std::string& zipPath = ""); + static std::vector> createNpcs( + const std::vector& definitions, + Renderer& renderer, + const std::unordered_map>& states, + IGameServer& server, const std::string& locationId, + const std::string& zipPath); + static StaticObjectSet loadAndCreateGameObjects( const std::string& jsonPath, Renderer& renderer, diff --git a/src/items/InteractiveObject.cpp b/src/items/InteractiveObject.cpp index 3ff55ad..0fb7cf6 100644 --- a/src/items/InteractiveObject.cpp +++ b/src/items/InteractiveObject.cpp @@ -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(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(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(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 diff --git a/src/items/InteractiveObject.h b/src/items/InteractiveObject.h index 9484959..acc4f58 100644 --- a/src/items/InteractiveObject.h +++ b/src/items/InteractiveObject.h @@ -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 }; diff --git a/src/items/InteractiveObjectState.cpp b/src/items/InteractiveObjectState.cpp new file mode 100644 index 0000000..2c4cd11 --- /dev/null +++ b/src/items/InteractiveObjectState.cpp @@ -0,0 +1,181 @@ +#include "InteractiveObjectState.h" +#include +#include + +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(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(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(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 diff --git a/src/items/Inventory.cpp b/src/items/Inventory.cpp new file mode 100644 index 0000000..5f6ded8 --- /dev/null +++ b/src/items/Inventory.cpp @@ -0,0 +1,43 @@ +#include "Item.h" +#include "external/nlohmann/json.hpp" +#include +#include + +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()); + } +} + +} // namespace FRG diff --git a/src/items/Item.cpp b/src/items/Item.cpp index d3db3e4..f73fc43 100644 --- a/src/items/Item.cpp +++ b/src/items/Item.cpp @@ -1,33 +1,8 @@ #include "Item.h" #include "ItemRegistry.h" -#include "external/nlohmann/json.hpp" -#include -#include -#include -#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 Inventory::getItems() const { std::vector 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()); - } - } - } // namespace FRG \ No newline at end of file diff --git a/src/navigation/PathFinder.h b/src/navigation/PathFinder.h index adb92fc..f428213 100644 --- a/src/navigation/PathFinder.h +++ b/src/navigation/PathFinder.h @@ -1,6 +1,5 @@ #pragma once -#include "render/Renderer.h" #include #include #include diff --git a/src/utils/HeadlessUtils.cpp b/src/utils/HeadlessUtils.cpp new file mode 100644 index 0000000..0951d9c --- /dev/null +++ b/src/utils/HeadlessUtils.cpp @@ -0,0 +1,78 @@ +#include "Utils.h" +#include +#include +#include +#include +#include + +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(file), + std::istreambuf_iterator()); +} + +std::vector 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 data(static_cast(size)); + if (!file.read(data.data(), size)) return {}; + return data; +} + +std::vector 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