From 5e9a66381245b5f2b4bf6643b1f6b7682502be46 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Thu, 29 Oct 2026 22:37:30 +0600 Subject: [PATCH] More work on states, added bot --- GAME_STATE_ARCHITECTURE.md | 116 +++-- src/Character.cpp | 373 +++++++++------ src/Character.h | 45 +- src/CharacterState.cpp | 11 + src/CharacterState.h | 1 + src/Game.cpp | 339 +++++++------- src/Game.h | 8 +- src/GameState.cpp | 1 + src/Location.cpp | 622 ++++++++++++++++++------- src/Location.h | 38 +- src/LocationEditor.cpp | 46 +- src/LocationState.h | 6 - src/ScriptEngine.cpp | 119 ++--- src/ScriptEngine.h | 9 +- src/dialogue/DialogueRuntime.cpp | 34 +- src/dialogue/DialogueRuntime.h | 28 +- src/dialogue/DialogueSystem.h | 13 +- src/game_server/IGameServer.h | 66 +++ src/game_server/InternalGameServer.cpp | 287 +++++++++++- src/game_server/InternalGameServer.h | 35 +- src/items/Item.cpp | 9 +- src/items/Item.h | 9 +- src/quest/QuestJournal.cpp | 69 +-- src/quest/QuestJournal.h | 37 +- src/render/PathVisualization.cpp | 14 +- 25 files changed, 1595 insertions(+), 740 deletions(-) diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index 238973a..c56a724 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -12,7 +12,7 @@ The architecture has four layers: 1. `GameState` is the aggregate root for persistent, authoritative state. 2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots. -3. Runtime objects such as `Location`, `Character`, and `InteractiveObject` hold rendering resources and non-owning bindings to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands. +3. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands. 4. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`. ```text @@ -47,6 +47,8 @@ IGameServer ---- immutable GameSnapshot ----> UI and other clients - Inventory, money, player-health HUD values, quests, tutorial progress, chat data, taxi state, and script-global values. Live `Location` pointers and rendering objects are no longer stored in `GameState`. +Embedded state models such as `Inventory` and `QuestJournal` contain only value +data and deterministic operations; they do not retain runtime callbacks. ### Campaign-level players @@ -54,6 +56,11 @@ Player `CharacterState` objects are stored in `GameState::players`. The current The player state is no longer owned by a `Character` or duplicated as a separately authoritative state for each loaded location. This permits additional player IDs to be introduced later without changing the aggregate ownership model. +Every player, including the one controlled by the current client, remains in this +same authoritative collection. `IGameServer::localPlayerId()` identifies which +entry is local to a particular client connection; it is not a separate state +object. + `LocationGameState` does not retain a player pointer or back-reference to `GameState`. Its update boundary receives `GameState::players` by reference, allowing character lookup and simulation to treat every campaign player by stable ID. @@ -70,7 +77,12 @@ Each location record owns: - Serialized dialogue runtime state. - Serialized Lua runtime globals. -State objects use `shared_ptr` internally to keep their addresses stable while local runtime objects are bound to them. These pointers never appear in public snapshots. +World-mode flags (`darklands`, `night`, and `dawn`) exist only at the aggregate +`GameState` level. Location rendering and scripts read those values from the +top-level snapshot rather than maintaining per-location mirrors. + +State objects use `shared_ptr` internally to keep addresses stable for the +remaining local runtime bindings. These pointers never appear in public snapshots. ### Stable identity @@ -121,6 +133,7 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches - Chat history and unread state. - Taxi state. - Inventory additions and removals. +- Quest status, objective completion/visibility, and active-objective changes. - Script globals. - Character transforms, attack targets, navigation destinations, stopping, facing, replanning, health, damage, death reset, attack combos, and animation @@ -131,6 +144,14 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches A future network implementation can serialize equivalent requests instead of mutating local state. +### Presentation events + +`IGameServer::drainEvents()` returns value-only events produced by successful +authoritative mutations. Inventory add/remove and quest unlock/complete/fail/ +objective-complete events contain stable IDs only. `Game` drains these events and +performs local tutorial, toast, and audio presentation through `MenuManager`. +Loading and persistence restoration clear pending events and remain silent. + ## Internal Server `InternalGameServer` is the current `IGameServer` implementation. It: @@ -143,10 +164,10 @@ A future network implementation can serialize equivalent requests instead of mut - Builds and caches immutable snapshots. - Delegates save/load serialization to the aggregate state model. -`Game` remains a privileged host during this migration. It can access private -internal-server operations for campaign loading, location construction, callback -wiring, and persistence restoration. These host operations are not exposed by -`IGameServer`. +`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`. ### Animation and combat @@ -180,16 +201,25 @@ collision handling, and presentation-event processing. ## Runtime and Visual Object Bindings -`Location`, `Character`, and `InteractiveObject` no longer own their authoritative state objects. +Runtime visual objects do not own their authoritative state objects. -- `Location` keeps a private non-owning reference to its `LocationState` and a stable location ID. -- `Character` keeps a private non-owning **const** reference named `characterState`, plus stable entity and location IDs. +- `Location` keeps only its `IGameServer` reference and stable location ID. It resolves its immutable `LocationState` view from `IGameServer::snapshot()`. +- `Character` keeps only its `IGameServer` reference plus stable entity and location IDs. It resolves the player-first character view from `IGameServer::snapshot()`. - `InteractiveObject` keeps a private non-owning reference to its `InteractiveObjectState`; its configured object name is its stable ID. -The bound state objects are owned by `GameState` or `LocationGameState`. The internal -server advances them directly before runtime presentation. Rendering reads the bound -state; runtime input, scripts, and collision handling submit semantic commands that -invoke `CharacterState` operations against the authoritative `LocationState`. +`Character::stateSnapshot()` and `Location::stateSnapshot()` return aliasing +`shared_ptr` views. Each pointer addresses a state copy inside a `GameSnapshot`, +while shared ownership keeps that whole immutable snapshot alive. Update, input, +editor, and draw operations capture one such pointer and use it consistently +throughout the operation. A missing or unloaded entity produces an empty pointer +and presentation or input work is skipped. + +The authoritative state objects are owned by `GameState` or `LocationGameState`. +The internal server advances them before publishing a new snapshot revision. +Runtime input, scripts, and collision handling submit semantic commands that invoke +authoritative character and location operations. Location commands cover camera +orientation, pending interaction targets, navigation switching and area changes, +editor obstacles, and taxi reset. No whole-location commit command is exposed. Navigation target selection, replanning, stopping, facing, death reset, health assignment, and attack-combo clock changes are implemented by `CharacterState`. @@ -203,12 +233,19 @@ Arrival behavior is represented solely by the serialized Lua function name in `CharacterState`; `Location` resolves and invokes that name through `ScriptEngine` when it consumes the arrival event. +`Location` owns presentation-only runtime characters for all non-local entries in +`GameSnapshot::players`. It reconciles them by stable ID as snapshots change, +reuses the local-player assets and animation set, and includes them in all render, +shadow, lighting, animation, damage, and name-tag passes. The local entry is +excluded using `IGameServer::localPlayerId()` because its visual is already owned +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 bound to those state objects. +4. Constructs visual objects using stable IDs and state-backed creation data. Configuration remains the source of visual assets, animation paths, meshes, textures, and fixed tuning data. @@ -225,11 +262,14 @@ The normal frame flow is: back as semantic destination commands, whose server handler calls `CharacterState::setTarget()` with the authoritative `LocationState`; arrival function names are resolved through `ScriptEngine`. -6. Runtime characters apply the authoritative animation frame to loaded models and +6. The server invalidates its cached snapshot and advances the revision so runtime + presentation can observe the completed simulation tick. +7. Runtime characters read one immutable snapshot per operation, apply the + authoritative animation frame to loaded models, and play audio, particles, and presentation callbacks. -7. Auxiliary dialogue, Lua, trigger, and teleport state is captured into `LocationGameState`. -8. The server increments its revision and invalidates the cached snapshot. -9. Rendering reads the bound local state; UI and external-style consumers read an immutable snapshot. +8. Auxiliary dialogue, Lua, trigger, and teleport state is captured into `LocationGameState`. +9. The server publishes those auxiliary changes in a later revision. +10. Rendering, UI, and external-style consumers read immutable snapshots. Commands that update bound character or object state do not reload the whole location. This avoids replaying dialogue-node effects or Lua callbacks. @@ -244,7 +284,7 @@ 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 and bound to that state. +- The visual player is created with its stable ID and resolves that state through snapshots. ### Location loading @@ -254,6 +294,8 @@ When a location is loaded: - Static configuration is loaded. - NPC and interactive-object visuals are created against state owned by the location record. - 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`. @@ -278,8 +320,8 @@ unsupported versions are rejected rather than migrated. - Pending interaction targets and character references use stable IDs. - Immutable content such as meshes, initial taxi configuration, and object definitions is reconstructed from campaign configuration. -- Configuration is loaded first and mutable saved state is overlaid in place, so - runtime render bindings keep stable addresses. +- Configuration is loaded first and mutable saved state is overlaid in place. + Snapshot-backed runtime objects then resolve the resulting state by stable ID. Old user save slots are intentionally unsupported. Bundled start-state snapshots must be regenerated with `Game::saveInitialState()` whenever the schema changes. @@ -298,11 +340,14 @@ It now: ### Scripts -Lua remains an in-process single-player runtime. Character bindings submit semantic -commands through `Location`; the server invokes `CharacterState` operations on the -centrally owned state. NPC, interactive-object, trigger-zone, and teleport-zone -bindings address entities by configured ID. Object, inventory, quest, and -global-state bindings remain trusted local integrations. +Lua remains an in-process single-player runtime. Character, inventory, quest, and +campaign-global bindings submit semantic commands through `Location`; the server +invokes operations on centrally owned state. Snapshot queries provide global +values, inventory counts, item presence, and synchronous quest validation for the +existing Lua API. NPC, +interactive-object, trigger-zone, and teleport-zone bindings address entities by +configured ID. Other object and global-state bindings remain trusted local +integrations. Each location script registers initialization, enter, and exit callbacks. Initialization runs only when the location has no retained state; loading the Lua @@ -313,13 +358,18 @@ The scalar Lua runtime state for each location is stored under `LocationGameStat ### Dialogue and cutscenes -Dialogue runtime state is captured in each `LocationGameState`. Dialogue and cutscene rendering remain local presentation systems. +Dialogue runtime state is captured in each `LocationGameState`. Dialogue global +flags use snapshot getter and command setter callbacks rather than retaining a +mutable map pointer. Dialogue and cutscene rendering remain local presentation +systems. Dialogues and cutscenes are outside the planned multiplayer gameplay mode, but their existing single-player save and callback behavior is retained. ### Quests, inventory, and tutorial -These systems remain part of `GameState` for single-player compatibility. UI access now crosses the server interface. They can remain empty or disabled in multiplayer mode as planned. +These callback-free state systems remain part of `GameState` for single-player +compatibility. UI access, mutations, and presentation notifications cross the +server interface. They can remain empty or disabled in multiplayer mode as planned. ## Multiplayer Readiness @@ -372,7 +422,11 @@ 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` currently uses privileged internal-server APIs for local content construction. A remote client implementation will need a client-side runtime-location factory driven by configuration and snapshots. +- `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. ## Main Files @@ -383,8 +437,8 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo - - `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. -- `src/Character.h` / `src/Character.cpp` — passive character presentation bound to - a const authoritative `CharacterState`. +- `src/Character.h` / `src/Character.cpp` — passive character presentation resolving + immutable `CharacterState` snapshots by stable ID. - `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. diff --git a/src/Character.cpp b/src/Character.cpp index 5195dd5..bd255ca 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -1,4 +1,4 @@ -#include "Character.h" +#include "Character.h" #include "render/TextRenderer.h" #include #include @@ -19,6 +19,44 @@ namespace FRG { Dialogue::TranslationDatabase Character::nameTranslations; +std::shared_ptr Character::stateSnapshot() const { + auto snapshot = gameServer_.snapshot(); + if (!snapshot) return {}; + + auto player = snapshot->players.find(entityId_); + if (player != snapshot->players.end()) { + return std::shared_ptr( + std::move(snapshot), &player->second.state); + } + + auto location = snapshot->locations.find(locationId_); + if (location == snapshot->locations.end()) return {}; + auto npc = location->second.npcs.find(entityId_); + if (npc == location->second.npcs.end()) return {}; + return std::shared_ptr( + std::move(snapshot), &npc->second.state); +} + +bool Character::isMoving() const { + auto state = stateSnapshot(); + return state && state->isMoving(); +} + +Eigen::Vector3f Character::getRequestedWalkTarget() const { + auto state = stateSnapshot(); + return state ? state->getRequestedWalkTarget() : Eigen::Vector3f::Zero(); +} + +Eigen::Vector3f Character::getCurrentNavigationTarget() const { + auto state = stateSnapshot(); + return state ? state->getCurrentNavigationTarget() : Eigen::Vector3f::Zero(); +} + +float Character::getHp() const { + auto state = stateSnapshot(); + return state ? state->getHp() : 0.f; +} + void Character::loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile) { auto& data = animations[animState]; data.model.LoadFromFile(filename, zipFile); @@ -46,7 +84,7 @@ void Character::loadBinaryAnimation(AnimationState animState, const std::string& std::unique_ptr Character::createFromState(const CharacterState& st, IGameServer& server, const std::string& locationId, const std::string& entityId, Renderer& renderer, const char* zipFile) { - auto c = std::make_unique(st, server, locationId, entityId); + auto c = std::make_unique(server, locationId, entityId); const std::string zip = zipFile ? zipFile : ""; @@ -62,7 +100,7 @@ std::unique_ptr Character::createFromState(const CharacterState& st, } }; - for (const auto& [animState, path] : c->characterState.creationInfo.animationPaths) + for (const auto& [animState, path] : st.creationInfo.animationPaths) loadAnim(animState, path); auto loadTex = [&](const std::string& path) -> std::shared_ptr { @@ -76,27 +114,27 @@ std::unique_ptr Character::createFromState(const CharacterState& st, } }; - if (!c->characterState.creationInfo.singleTexturePath.empty()) { - auto tex = loadTex(c->characterState.creationInfo.singleTexturePath); + if (!st.creationInfo.singleTexturePath.empty()) { + auto tex = loadTex(st.creationInfo.singleTexturePath); if (tex) c->setTexture(tex); } - for (const auto& [meshName, texPath] : c->characterState.creationInfo.meshTexturePaths) { + for (const auto& [meshName, texPath] : st.creationInfo.meshTexturePaths) { auto tex = loadTex(texPath); if (tex) c->setTexture(meshName, tex); } - if (!c->characterState.creationInfo.weaponMeshPath.empty()) { - const std::string& wp = c->characterState.creationInfo.weaponMeshPath; + if (!st.creationInfo.weaponMeshPath.empty()) { + const std::string& wp = st.creationInfo.weaponMeshPath; try { if (wp.size() >= 4 && wp.substr(wp.size() - 4) == ".bin") c->weaponMesh.data = LoadModelFromBinFile(wp, zip); else c->weaponMesh.data = LoadFromTextFile02(wp, zip); - if (c->characterState.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero()) - c->weaponMesh.data.Move(c->characterState.creationInfo.weaponMeshMove); - if (c->characterState.creationInfo.weaponMeshScale != 1.0f) - c->weaponMesh.data.Scale(c->characterState.creationInfo.weaponMeshScale); + if (st.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero()) + c->weaponMesh.data.Move(st.creationInfo.weaponMeshMove); + if (st.creationInfo.weaponMeshScale != 1.0f) + c->weaponMesh.data.Scale(st.creationInfo.weaponMeshScale); c->weaponMesh.RefreshVBO(); } catch (const std::exception& e) { @@ -104,10 +142,10 @@ std::unique_ptr Character::createFromState(const CharacterState& st, } } - c->weaponTexture = loadTex(c->characterState.creationInfo.weaponTexturePath); - c->weaponAttachBoneName = c->characterState.creationInfo.weaponAttachBoneName; - c->weaponInitialPosition = c->characterState.creationInfo.weaponInitialPosition; - c->weaponInitialRotation = c->characterState.creationInfo.weaponInitialRotation.toRotationMatrix(); + c->weaponTexture = loadTex(st.creationInfo.weaponTexturePath); + c->weaponAttachBoneName = st.creationInfo.weaponAttachBoneName; + c->weaponInitialPosition = st.creationInfo.weaponInitialPosition; + c->weaponInitialRotation = st.creationInfo.weaponInitialRotation.toRotationMatrix(); return c; } @@ -124,8 +162,8 @@ void Character::setTexture(const std::string& meshName, std::shared_ptr meshTextures[meshName] = std::move(tex); } -AnimationState Character::resolveActiveState() const { - if (animations.count(characterState.currentState)) return characterState.currentState; +AnimationState Character::resolveActiveState(const CharacterState& state) const { + if (animations.count(state.currentState)) return state.currentState; return AnimationState::STAND; } @@ -134,29 +172,33 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) { hitSparkEmitter.update(static_cast(deltaMs)); } + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (events) { if (events->deathAnimationComplete && onDeathAnimComplete) onDeathAnimComplete(); if (events->attackImpact && audioPlayer) { - audioPlayer->playSoundAsync(characterState.isPlayer + audioPlayer->playSoundAsync(state.isPlayer ? "resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg" : "resources/audio/13925__adcbicycle__21.ogg"); } } - if (!characterState.enabled) return; - auto it = animations.find(characterState.currentState); + if (!state.enabled) return; + auto it = animations.find(state.currentState); if (it == animations.end()) return; auto& anim = it->second; anim.currentFrame = (std::min)( - characterState.animationFrame, static_cast((std::max)(0, anim.totalFrames - 1))); + state.animationFrame, static_cast((std::max)(0, anim.totalFrames - 1))); if (static_cast(anim.currentFrame) != anim.lastFrame) { anim.gpuSkinningShaderData.ComputeSkinningMatrices( anim.model.startBones, anim.model.animations[0].keyFrames, static_cast(anim.currentFrame)); - if (!characterState.useGpuSkinning) { + if (!state.useGpuSkinning) { anim.model.Interpolate(static_cast(anim.currentFrame)); } anim.lastFrame = static_cast(anim.currentFrame); @@ -171,14 +213,17 @@ void Character::draw(Renderer& renderer) { renderer.RenderUniform1f("uAlpha", 1.0f); glDisable(GL_BLEND); - if (!characterState.enabled) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (!state.enabled) return; - if (characterState.useGpuSkinning) { - drawGpuSkinning(renderer); + if (state.useGpuSkinning) { + drawGpuSkinning(renderer, state); return; } - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; @@ -192,10 +237,10 @@ void Character::draw(Renderer& renderer) { Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); auto& anim = it->second; for (const auto& name : anim.model.meshNamesOrdered) { @@ -211,14 +256,14 @@ void Character::draw(Renderer& renderer) { renderer.PopMatrix(); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); } -void Character::drawGpuSkinning(Renderer& renderer) { - AnimationState drawState = resolveActiveState(); +void Character::drawGpuSkinning(Renderer& renderer, const CharacterState& state) { + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; @@ -246,10 +291,10 @@ void Character::drawGpuSkinning(Renderer& renderer) { Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RenderUniformMatrix4fvArray(boneMatricesUniform, static_cast(anim.gpuSkinningShaderData.skinningMatrices.size()), false, @@ -269,15 +314,15 @@ void Character::drawGpuSkinning(Renderer& renderer) { renderer.shaderManager.PushShader("fog"); renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosSkin); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.shaderManager.PopShader(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); } -bool Character::prepareGpuSkinning() { - AnimationState drawState = resolveActiveState(); +bool Character::prepareGpuSkinning(const CharacterState& state) { + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return false; @@ -293,11 +338,11 @@ bool Character::prepareGpuSkinning() { return true; } -void Character::drawAttachedWeapon(Renderer& renderer) { - if (!characterState.showWeapon) return; +void Character::drawAttachedWeapon(Renderer& renderer, const CharacterState& state) { + if (!state.showWeapon) return; if (weaponMesh.data.PositionData.size() == 0 || !weaponTexture) return; - auto it = animations.find(resolveActiveState()); + auto it = animations.find(resolveActiveState(state)); if (it == animations.end()) return; auto& anim = it->second; @@ -310,10 +355,10 @@ void Character::drawAttachedWeapon(Renderer& renderer) { Eigen::Matrix4f currentBone = anim.gpuSkinningShaderData.skinningMatrices[boneIdx] * bindBone; renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.TranslateMatrix(Eigen::Vector3f(currentBone.block<3, 1>(0, 3))); renderer.RotateMatrix(Eigen::Matrix3f(currentBone.block<3, 3>(0, 0))); renderer.TranslateMatrix(weaponInitialPosition); @@ -327,24 +372,27 @@ void Character::drawAttachedWeapon(Renderer& renderer) { // ==================== Shadow depth pass ==================== void Character::drawShadowDepth(Renderer& renderer) { - if (!characterState.enabled) return; - if (characterState.useGpuSkinning) { - drawShadowDepthGpuSkinning(renderer); + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (!state.enabled) return; + if (state.useGpuSkinning) { + drawShadowDepthGpuSkinning(renderer, state); } else { - drawShadowDepthCpu(renderer); + drawShadowDepthCpu(renderer, state); } } -void Character::drawShadowDepthCpu(Renderer& renderer) { - AnimationState drawState = resolveActiveState(); +void Character::drawShadowDepthCpu(Renderer& renderer, const CharacterState& state) { + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); auto& anim = it->second; for (const auto& name : anim.model.meshNamesOrdered) { @@ -358,17 +406,17 @@ void Character::drawShadowDepthCpu(Renderer& renderer) { } renderer.PopMatrix(); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); } -void Character::drawShadowDepthGpuSkinning(Renderer& renderer) { +void Character::drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterState& state) { CheckGlError(__FILE__, __LINE__); - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; CheckGlError(__FILE__, __LINE__); - if (!prepareGpuSkinning()) return; + if (!prepareGpuSkinning(state)) return; CheckGlError(__FILE__, __LINE__); static const std::string shadowSkinningShader = "shadow_depth_skinning"; @@ -377,10 +425,10 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) { renderer.shaderManager.PushShader(shadowSkinningShader); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); CheckGlError(__FILE__, __LINE__); renderer.RenderUniformMatrix4fvArray(boneMatricesUniform, @@ -400,14 +448,17 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) { renderer.PopMatrix(); renderer.shaderManager.PopShader(); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); CheckGlError(__FILE__, __LINE__); } // ==================== Main pass with shadows ==================== void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { - if (!characterState.enabled) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (!state.enabled) return; if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } @@ -415,15 +466,15 @@ void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightF renderer.RenderUniform1f("uAlpha", 1.0f); glDisable(GL_BLEND); - if (characterState.useGpuSkinning) { - drawGpuSkinningWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera); + if (state.useGpuSkinning) { + drawGpuSkinningWithShadow(renderer, state, lightFromCamera, shadowMapTex, lightDirCamera); } else { - drawCpuWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera); + drawCpuWithShadow(renderer, state, lightFromCamera, shadowMapTex, lightDirCamera); } } -void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { - AnimationState drawState = resolveActiveState(); +void Character::drawCpuWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; @@ -446,10 +497,10 @@ void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lig Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); auto& anim = it->second; for (const auto& name : anim.model.meshNamesOrdered) { @@ -468,20 +519,20 @@ void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lig renderer.shaderManager.PushShader("fog"); renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosShadow); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.shaderManager.PopShader(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); } -void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { - AnimationState drawState = resolveActiveState(); +void Character::drawGpuSkinningWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; CheckGlError(__FILE__, __LINE__); - if (!prepareGpuSkinning()) return; + if (!prepareGpuSkinning(state)) return; CheckGlError(__FILE__, __LINE__); static const std::string skinningShadowShader = "fog_skinning_shadow"; @@ -508,10 +559,10 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); CheckGlError(__FILE__, __LINE__); renderer.RenderUniformMatrix4fvArray(boneMatricesUniform, @@ -534,7 +585,7 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri renderer.shaderManager.PushShader("fog"); renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosSkinShadow); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.shaderManager.PopShader(); CheckGlError(__FILE__, __LINE__); @@ -554,7 +605,10 @@ void Character::drawNight(Renderer& renderer, const Eigen::Matrix4f& cameraViewInverse, const float* ambientColor, const float* fogColor) { - if (!characterState.enabled) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (!state.enabled) return; if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } @@ -562,25 +616,26 @@ void Character::drawNight(Renderer& renderer, renderer.RenderUniform1f("uAlpha", 1.0f); glDisable(GL_BLEND); - if (characterState.useGpuSkinning) { - drawNightGpuSkinning(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount, + if (state.useGpuSkinning) { + drawNightGpuSkinning(renderer, state, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount, pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor); } else { - drawNightCpu(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount, + drawNightCpu(renderer, state, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount, pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor); } } void Character::drawNightGpuSkinning(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const float* ambientColor, const float* fogColor) { - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; - if (!prepareGpuSkinning()) return; + if (!prepareGpuSkinning(state)) return; static const std::string nightSkinningShader = "night_fog_skinning"; static const std::string boneMatricesUniform = "uBoneMatrices[0]"; @@ -606,10 +661,10 @@ void Character::drawNightGpuSkinning(Renderer& renderer, Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RenderUniformMatrix4fvArray(boneMatricesUniform, static_cast(it->second.gpuSkinningShaderData.skinningMatrices.size()), false, @@ -639,7 +694,7 @@ void Character::drawNightGpuSkinning(Renderer& renderer, renderer.RenderUniform2fvArray("uPointLightWorldXZ[0]", plCount, plWorldXZ); renderer.RenderUniform2fvArray("uPointLightLimitXZ[0]", plCount, plLimitXZ); } - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.shaderManager.PopShader(); renderer.PopProjectionMatrix(); @@ -647,11 +702,12 @@ void Character::drawNightGpuSkinning(Renderer& renderer, } void Character::drawNightCpu(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const float* ambientColor, const float* fogColor) { - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; @@ -677,10 +733,10 @@ void Character::drawNightCpu(Renderer& renderer, Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); auto& anim = it->second; for (const auto& name : anim.model.meshNamesOrdered) { @@ -697,7 +753,7 @@ void Character::drawNightCpu(Renderer& renderer, renderer.PopMatrix(); renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosNight); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); @@ -712,35 +768,39 @@ void Character::drawNightWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor) { - if (!characterState.enabled) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (!state.enabled) return; if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } renderer.RenderUniform1f("uAlpha", 1.0f); glDisable(GL_BLEND); - if (characterState.useGpuSkinning) { - drawNightGpuSkinningWithShadow(renderer, plPosEye, plDirEye, plColors, plCount, + if (state.useGpuSkinning) { + drawNightGpuSkinningWithShadow(renderer, state, plPosEye, plDirEye, plColors, plCount, plWorldXZ, plLimitXZ, cameraViewInverse, lightFromCamera, shadowMapTex, ambientColor, fogColor); } else { - drawNightCpuWithShadow(renderer, plPosEye, plDirEye, plColors, plCount, + drawNightCpuWithShadow(renderer, state, plPosEye, plDirEye, plColors, plCount, plWorldXZ, plLimitXZ, cameraViewInverse, lightFromCamera, shadowMapTex, ambientColor, fogColor); } } void Character::drawNightGpuSkinningWithShadow(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor) { - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; - if (!prepareGpuSkinning()) return; + if (!prepareGpuSkinning(state)) return; static const std::string nightShadowSkinningShader = "night_fog_skinning_shadow"; static const std::string boneMatricesUniform = "uBoneMatrices[0]"; @@ -774,10 +834,10 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer, Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RenderUniformMatrix4fvArray(boneMatricesUniform, static_cast(it->second.gpuSkinningShaderData.skinningMatrices.size()), false, @@ -810,7 +870,7 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer, } renderer.RenderUniform2fvArray("uPointLightWorldXZ[0]", plCount, plWorldXZ); renderer.RenderUniform2fvArray("uPointLightLimitXZ[0]", plCount, plLimitXZ); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.shaderManager.PopShader(); renderer.PopProjectionMatrix(); @@ -818,13 +878,14 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer, } void Character::drawNightCpuWithShadow(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor) { - AnimationState drawState = resolveActiveState(); + AnimationState drawState = resolveActiveState(state); auto it = animations.find(drawState); if (it == animations.end()) return; @@ -858,10 +919,10 @@ void Character::drawNightCpuWithShadow(Renderer& renderer, Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); renderer.PushMatrix(); - renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); - renderer.ScaleMatrix(characterState.modelScale); - renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix()); + renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.ScaleMatrix(state.modelScale); + renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); auto& anim = it->second; for (const auto& name : anim.model.meshNamesOrdered) { @@ -878,7 +939,7 @@ void Character::drawNightCpuWithShadow(Renderer& renderer, renderer.PopMatrix(); renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosNightShadow); - drawAttachedWeapon(renderer); + drawAttachedWeapon(renderer, state); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); @@ -906,8 +967,11 @@ void Character::presentDamage(float hp, float maxHp, onHpChanged(hp, maxHp, attackerId); if (!hitSparkEmitter.isConfigured()) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; - Eigen::Vector3f emitPos = characterState.position + Eigen::Vector3f(0.f, 1.f, 0.f); + Eigen::Vector3f emitPos = state.position + Eigen::Vector3f(0.f, 1.f, 0.f); hitSparkEmitter.resetEmissionPoints({ emitPos }); hitSparkEmitter.setEmissionDirection(attackDirection); @@ -924,16 +988,18 @@ void Character::prepareHitSparksForDraw(const Eigen::Matrix4f& cameraViewMatrix) hitSparkEmitter.prepareForDraw(cameraViewMatrix); } -void Character::drawName(TextRenderer& textRenderer, - const Eigen::Matrix4f& cameraViewMatrix, - const Eigen::Matrix4f& projectionMatrix) +static void drawCharacterNameLabel( + TextRenderer& textRenderer, + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix, + const CharacterState& state, + const std::string& displayName, + const std::array& color) { - if (characterState.isPlayer) return; - if (!characterState.enabled) return; - if (characterState.hp <= 0.f) return; - if (characterState.npcName.empty()) return; + if (!state.enabled || state.hp <= 0.f || displayName.empty()) return; - Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.83f, characterState.position.z(), 1.f); + Eigen::Vector4f worldPos( + state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f); Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos; if (clip.w() <= 0.f) return; @@ -943,26 +1009,57 @@ void Character::drawName(TextRenderer& textRenderer, float screenX = (ndcX * 0.5f + 0.5f) * Environment::projectionWidth; float screenY = (ndcY * 0.5f + 0.5f) * Environment::projectionHeight; + textRenderer.drawText(displayName, screenX, screenY, 1.0f, true, color); +} - std::array color = characterState.canAttack +void Character::drawName(TextRenderer& textRenderer, + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix) +{ + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (state.isPlayer) return; + if (state.npcName.empty()) return; + + std::array color = state.canAttack ? std::array{ 1.f, 0.f, 0.f, 1.f } : std::array{ 0.f, 1.f, 0.f, 1.f }; - const std::string& translatedName = Character::nameTranslations.translateRef(characterState.npcName, g_currentLanguage); + const std::string& translatedName = Character::nameTranslations.translateRef(state.npcName, g_currentLanguage); - textRenderer.drawText(translatedName, screenX, screenY, 1.0f, true, color); + drawCharacterNameLabel( + textRenderer, cameraViewMatrix, projectionMatrix, + state, translatedName, color); +} + +void Character::drawPlayerName( + TextRenderer& textRenderer, + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix, + const std::string& displayName) +{ + auto snapshot = stateSnapshot(); + if (!snapshot) return; + static const std::array color = {0.f, 1.f, 0.f, 1.f}; + drawCharacterNameLabel( + textRenderer, cameraViewMatrix, projectionMatrix, + *snapshot, displayName, color); } void Character::drawHealthBar(Renderer& renderer, const Eigen::Matrix4f& cameraViewMatrix, const Eigen::Matrix4f& projectionMatrix) { - if (characterState.isPlayer || !characterState.canAttack) return; - if (!characterState.enabled) return; - if (characterState.hp <= 0.f) return; - if (characterState.initialHp <= 0.f) return; + auto stateSnapshot = this->stateSnapshot(); + if (!stateSnapshot) return; + const CharacterState& state = *stateSnapshot; + if (state.isPlayer || !state.canAttack) return; + if (!state.enabled) return; + if (state.hp <= 0.f) return; + if (state.initialHp <= 0.f) return; - Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.75f, characterState.position.z(), 1.f); + Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.75f, state.position.z(), 1.f); Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos; if (clip.w() <= 0.f) return; float ndcX = clip.x() / clip.w(); @@ -980,7 +1077,7 @@ void Character::drawHealthBar(Renderer& renderer, const float bottom = sy - barH * 0.5f; const float top = sy + barH * 0.5f; - float frac = characterState.hp / characterState.initialHp; + float frac = state.hp / state.initialHp; if (frac < 0.f) frac = 0.f; if (frac > 1.f) frac = 1.f; const float split = left + barW * frac; diff --git a/src/Character.h b/src/Character.h index aa9e56e..eb425c5 100644 --- a/src/Character.h +++ b/src/Character.h @@ -22,12 +22,14 @@ class IGameServer; class Character { public: - Character(const CharacterState& boundState, IGameServer& server, - std::string locationId, std::string entityId) - : characterState(boundState), gameServer_(server), - locationId_(std::move(locationId)), entityId_(std::move(entityId)){} + Character(IGameServer& server, std::string locationId, std::string entityId) + : gameServer_(server), locationId_(std::move(locationId)), + entityId_(std::move(entityId)) {} const std::string& entityId() const { return entityId_; } void bindToLocation(std::string locationId) { locationId_ = std::move(locationId); } + // The aliasing shared_ptr keeps the owning GameSnapshot alive for as long + // as the returned CharacterState is being read. + std::shared_ptr stateSnapshot() const; static constexpr float kAttackRange = 1.25f; // ---- Asset loading ---- @@ -63,6 +65,10 @@ public: void drawName(TextRenderer& textRenderer, const Eigen::Matrix4f& cameraViewMatrix, const Eigen::Matrix4f& projectionMatrix); + void drawPlayerName(TextRenderer& textRenderer, + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix, + const std::string& displayName); void drawHealthBar(Renderer& renderer, const Eigen::Matrix4f& cameraViewMatrix, const Eigen::Matrix4f& projectionMatrix); @@ -84,11 +90,11 @@ public: const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor); - // ---- Query helpers (delegate to state) ---- - bool isMoving() const { return characterState.isMoving(); } - const Eigen::Vector3f& getRequestedWalkTarget() const { return characterState.getRequestedWalkTarget(); } - Eigen::Vector3f getCurrentNavigationTarget() const { return characterState.getCurrentNavigationTarget(); } - float getHp() const { return characterState.getHp(); } + // ---- Query helpers (read immutable snapshots) ---- + bool isMoving() const; + Eigen::Vector3f getRequestedWalkTarget() const; + Eigen::Vector3f getCurrentNavigationTarget() const; + float getHp() const; // ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ---- VertexRenderStruct weaponMesh; @@ -109,7 +115,6 @@ private: friend class LocationEditor; friend class PathVisualization; friend class InternalGameServer; - const CharacterState& characterState; // private non-owning binding to server-owned storage IGameServer& gameServer_; std::string locationId_; std::string entityId_; @@ -120,28 +125,32 @@ private: VertexRenderStruct healthBarMesh; std::unordered_map> meshTextures; - AnimationState resolveActiveState() const; - bool prepareGpuSkinning(); - void drawAttachedWeapon(Renderer& renderer); + AnimationState resolveActiveState(const CharacterState& state) const; + bool prepareGpuSkinning(const CharacterState& state); + void drawAttachedWeapon(Renderer& renderer, const CharacterState& state); - void drawGpuSkinning(Renderer& renderer); - void drawShadowDepthGpuSkinning(Renderer& renderer); - void drawShadowDepthCpu(Renderer& renderer); - void drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera); - void drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera); + void drawGpuSkinning(Renderer& renderer, const CharacterState& state); + void drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterState& state); + void drawShadowDepthCpu(Renderer& renderer, const CharacterState& state); + void drawGpuSkinningWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera); + void drawCpuWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera); void drawNightGpuSkinning(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const float* ambientColor, const float* fogColor); void drawNightCpu(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const float* ambientColor, const float* fogColor); void drawNightGpuSkinningWithShadow(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor); void drawNightCpuWithShadow(Renderer& renderer, + const CharacterState& state, const float* plPosEye, const float* plDirEye, const float* plColors, int plCount, const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor); diff --git a/src/CharacterState.cpp b/src/CharacterState.cpp index c849ba0..a32050b 100644 --- a/src/CharacterState.cpp +++ b/src/CharacterState.cpp @@ -12,6 +12,17 @@ constexpr float TARGET_REPLAN_THRESHOLD = 0.25f; constexpr float PI = 3.14159265358979323846f; } +void CharacterState::configureAsPlayer() { + walkSpeed = 3.0f; + rotationSpeed = 8.0f; + modelScale = 1.f; + modelCorrectionRotation = Eigen::Quaternionf( + Eigen::AngleAxisf(PI, Eigen::Vector3f::UnitY()) * + Eigen::AngleAxisf(-PI * 0.5f, Eigen::Vector3f::UnitX())); + canAttack = true; + isPlayer = true; +} + bool CharacterState::isMoving() const { Eigen::Vector3f toTarget = walkTarget - position; toTarget.y() = 0.f; diff --git a/src/CharacterState.h b/src/CharacterState.h index fe3f634..b45ceed 100644 --- a/src/CharacterState.h +++ b/src/CharacterState.h @@ -135,6 +135,7 @@ public: CharacterCreationInfo creationInfo; // --- Pure-state methods --- + void configureAsPlayer(); bool isMoving() const; const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; } Eigen::Vector3f getCurrentNavigationTarget() const; diff --git a/src/Game.cpp b/src/Game.cpp index 55a9430..e065368 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #ifdef __ANDROID__ #include #endif @@ -76,16 +77,14 @@ namespace FRG #endif Location* Game::currentLocation() const { - if (gameState.currentLocationName.empty()) return nullptr; - const auto& locations = internalGameServer.runtimeLocations(); - auto it = locations.find(gameState.currentLocationName); - return (it != locations.end()) ? it->second.get() : nullptr; + const auto state = stateSnapshot(); + if (!state || state->currentLocationId.empty()) return nullptr; + return internalGameServer.runtimeLocation(state->currentLocationId); } Game::Game() : audioPlayer(std::make_unique()) , internalGameServer() - , gameState(internalGameServer.hostState()) , menuManager(renderer, internalGameServer, *audioPlayer) , newTickCount(0) , lastTickCount(0) @@ -360,19 +359,6 @@ namespace FRG CampaignRegistry::instance().loadFromJson("resources/config/campaigns.json", CONST_ZIP_FILE); loadSteps.push([this]() { - // Wire inventory callbacks: tutorial tracking + toast notifications. - gameState.inventory.onItemAdded = [this](const std::string& itemId) { - menuManager.onItemPickedUp(itemId); - const Item* item = ItemRegistry::instance().findById(itemId); - if (item) menuManager.showToast(menuManager.toastTextureItemAdded, item->name); - audioPlayer->playSoundAsync("resources/audio/831642__ienba__generic-game-pick-up.ogg"); - }; - gameState.inventory.onItemRemoved = [this](const std::string& itemId) { - const Item* item = ItemRegistry::instance().findById(itemId); - if (item) menuManager.showToast(menuManager.toastTextureItemRemoved, item->name); - audioPlayer->playSoundAsync("resources/audio/831642__ienba__generic-game-pick-up.ogg"); - }; - // Wire phone dialogue start function so MenuManager can trigger dialogues. menuManager.startDialogueFunc = [this](const std::string& id) { if (currentLocation()) currentLocation()->dialogueSystem.startDialogue(id); @@ -399,18 +385,6 @@ namespace FRG loadSteps.push([this]() { try { menuManager.setup(CONST_ZIP_FILE); - gameState.questJournal.onQuestUnlocked = [this](const std::string& id) { - const Quest::QuestState* quest = gameState.questJournal.findQuest(id); - if (quest) menuManager.showToast(menuManager.toastTextureQuestNew, quest->definition.title); - }; - gameState.questJournal.onQuestCompleted = [this](const std::string& id) { - const Quest::QuestState* quest = gameState.questJournal.findQuest(id); - if (quest) menuManager.showToast(menuManager.toastTextureQuestCompleted, quest->definition.title); - }; - gameState.questJournal.onQuestFailed = [this](const std::string& id) { - const Quest::QuestState* quest = gameState.questJournal.findQuest(id); - if (quest) menuManager.showToast(menuManager.toastTextureQuestFailed, quest->definition.title); - }; logger() << "UI loaded successfully" << std::endl; } catch (const std::exception& e) { @@ -425,7 +399,8 @@ namespace FRG performResetToInitialState(); applyTutorialSetting(); const CampaignConfig& campaign = activeCampaign(); - gameState.currentLocationName = campaign.startLocation; + internalGameServer.submit( + SetCurrentLocationCommand{campaign.startLocation}); menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback(); audioPlayer->crossFadeMusicAsync(campaign.startMusic); @@ -516,7 +491,10 @@ namespace FRG } const CampaignConfig& Game::activeCampaign() const { - const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState.campaignId); + const auto state = stateSnapshot(); + const CampaignConfig* campaign = state + ? CampaignRegistry::instance().find(state->campaignId) + : nullptr; if (!campaign) campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId); if (!campaign) { static const CampaignConfig empty; @@ -527,7 +505,7 @@ namespace FRG bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const { return player && loadedCampaignId == campaignId - && internalGameServer.runtimeLocations().count(locationId) > 0; + && internalGameServer.hasRuntimeLocation(locationId); } void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId, @@ -567,15 +545,15 @@ namespace FRG void Game::loadCampaign(const std::string& campaignId) { loadedCampaignId = campaignId; - gameState.campaignId = campaignId; - gameState.locations.clear(); - gameState.players.clear(); - gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE); - gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load + const CampaignConfig* campaign = + CampaignRegistry::instance().find(campaignId); + if (!campaign) return; + internalGameServer.initializeCampaign( + campaignId, campaign->questsPath, CONST_ZIP_FILE); // The player stays loaded for the whole campaign; locations come and go around him. - auto& playerState = gameState.players["player"]; - if (!playerState) playerState = std::make_shared(); - player = Location::createPlayer(renderer, audioPlayer.get(), *playerState, internalGameServer); + player = Location::createPlayer( + renderer, audioPlayer.get(), internalGameServer, + internalGameServer.localPlayerId()); if (player) player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; } @@ -583,21 +561,21 @@ namespace FRG void Game::unloadCampaign() { unloadLocation(false); player.reset(); - gameState.players.clear(); loadedCampaignId.clear(); - gameState.locations.clear(); + internalGameServer.clearCampaign(); renderer.textureManager.UnloadUnused(); // the player's textures } void Game::loadLocation(const std::string& locationId) { const LocationConfig* config = activeCampaign().findLocation(locationId); if (!config) { - std::cerr << "[campaign] Campaign '" << gameState.campaignId << "' has no location '" + const auto state = stateSnapshot(); + std::cerr << "[campaign] Campaign '" + << (state ? state->campaignId : std::string()) << "' has no location '" << locationId << "'" << std::endl; return; } - const bool initializeLocation = - !gameState.locations[locationId].hasPersistentState; + const bool initializeLocation = internalGameServer.prepareLocation(*config); std::shared_ptr loc = createLocation(*config); wireLocation(loc.get()); if (shadowMap) loc->shadowMap = shadowMap; @@ -608,12 +586,7 @@ namespace FRG } void Game::unloadLocation(bool storeState) { - if (storeState) internalGameServer.synchronizeRuntimeAuxiliaryState(); - else { - for (const auto& [name, loc] : internalGameServer.runtimeLocations()) - gameState.locations.erase(name); - } - internalGameServer.runtimeLocations().clear(); + internalGameServer.clearRuntimeLocations(storeState); // Textures only the location's models and characters used are freed now. renderer.textureManager.UnloadUnused(); } @@ -631,8 +604,10 @@ namespace FRG } // Runs from inside the current location's update(); the location itself is // released by a later loading step. - enterLocationThen(gameState.campaignId, destName, [this, destName, destPos, destRotY]() { - gameState.currentLocationName = destName; + const auto state = stateSnapshot(); + if (!state) return; + enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() { + internalGameServer.submit(SetCurrentLocationCommand{destName}); Location* loc = currentLocation(); if (!loc) return; if (loc->player) { @@ -643,17 +618,17 @@ namespace FRG }); loc->stopCharacter(*loc->player); } - loc->state.cameraAzimuth = destRotY; - loc->state.isDarklands = gameState.isDarklands; - loc->state.isNight = gameState.isNight; - loc->state.isDawn = gameState.isDawn; + auto locationState = loc->stateSnapshot(); + const float inclination = locationState + ? locationState->cameraInclination + : 1.1036f; + internalGameServer.submit(SetLocationCameraCommand{ + destName, destRotY, inclination}); loc->scriptEngine.callLocationEnterCallback(); //Reset taxi - auto locationState = gameState.locations.find(destName); - if (locationState != gameState.locations.end()) - locationState->second.resetTaxi(); - gameState.taxiIsCalled = false; + internalGameServer.submit(ResetTaxiCommand{destName}); + internalGameServer.submit(SetTaxiCalledCommand{false}); menuManager.onLocationChanged(destName); }); @@ -661,34 +636,12 @@ namespace FRG void Game::performFullReset() { - gameState.currentLocationName.clear(); - gameState.isDarklands = false; - gameState.isNight = false; - gameState.isDawn = false; - gameState.playerHp = 200.f; - gameState.playerMaxHp = 200.f; - gameState.money = 5500; - gameState.globalInts.clear(); - gameState.globalFloats.clear(); - gameState.globalFloats["player_hp"] = 200; - gameState.tutorialStep = TutorialStep::AdvanceDialogue; - gameState.tutorialPhoneChatScreenOpened = false; - gameState.tutorialJournalScreenOpened = false; - gameState.tutorialNeedOpenTaxiScreen = false; - gameState.tutorialTaxiHintDone = false; - for (int i = 0; i < 3; ++i) { - gameState.chatUnread[i] = true; - gameState.chatPreviewMsg[i].clear(); - gameState.chatHistory[i].clear(); - } - gameState.inventory.clear(); - gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE); - // Rebuild the loaded location from its config; the others have no stored state // any more, so they are built fresh when the player first goes there. - gameState.locations.clear(); - std::vector loadedIds; - for (auto& [name, loc] : internalGameServer.runtimeLocations()) loadedIds.push_back(name); + std::vector loadedIds = + internalGameServer.loadedRuntimeLocationIds(); + internalGameServer.resetCampaign( + activeCampaign().questsPath, CONST_ZIP_FILE); unloadLocation(false); for (const std::string& id : loadedIds) loadLocation(id); @@ -737,7 +690,8 @@ namespace FRG // (if the campaign has one); a disabled tutorial is simply one that is already completed. void Game::applyTutorialSetting() { if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting) - gameState.tutorialStep = TutorialStep::Completed; + internalGameServer.submit( + SetTutorialStepCommand{TutorialStep::Completed}); } void Game::saveInitialState() { @@ -760,12 +714,6 @@ namespace FRG std::shared_ptr Game::createLocation(const LocationConfig& config) { - LocationGameState& authoritative = gameState.locations[config.id]; - if (!authoritative.location) authoritative.location = std::make_shared(); - - if (authoritative.location->navigationMaps.empty()) - authoritative.location->initNavigation(config.navigationJsonPaths); - LocationSetup params; params.gameObjectsJsonPath = config.gameObjectsJsonPath; params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath; @@ -778,29 +726,30 @@ namespace FRG params.lightsJsonPath = config.lightsJsonPath; params.playerPosition = config.playerPosition; params.player = player; - params.gameState = &authoritative; auto loc = std::make_shared( - renderer, gameState.inventory, *authoritative.location, internalGameServer, config.id); - loc->setup(params, &gameState.questJournal, audioPlayer.get()); - loc->scriptEngine.setGlobalStore(&gameState.globalInts); - loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats); + renderer, internalGameServer, config.id); + internalGameServer.setupRuntimeLocation( + config.id, loc, std::move(params), audioPlayer.get()); loc->requestNightDayTransition = [this](bool isNight, bool isDawn) { - gameState.isNight = isNight; - gameState.isDawn = isDawn; + const auto state = stateSnapshot(); + if (!state) return; + internalGameServer.submit(SetWorldModeCommand{ + state->isDarklands, isNight, isDawn}); }; loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; loc->requestReturnToMainMenu = [this]() { - gameState.currentLocationName.clear(); + internalGameServer.submit(SetCurrentLocationCommand{std::string()}); menuManager.showMenuAfterGameCompleted(); }; loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { menuManager.setChatUnread(idx, unread, msg); }; for (auto& npc : loc->npcs) { - if (npc && npc->characterState.canAttack) { + auto state = npc ? npc->stateSnapshot() : nullptr; + if (state && state->canAttack) { npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); }; } } @@ -813,25 +762,6 @@ namespace FRG menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; }; - 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 (!authoritative.hasPersistentState) - authoritative.resetTaxi(); - } - return loc; } @@ -846,20 +776,16 @@ namespace FRG teleportPlayer(destName, destPos, destRotY); }; - // Share the global int store with all dialogue runtimes so flags set via - // dialogue JSON and via Lua set_dialogue_flag all see the same state. - { - loc->dialogueSystem.setGlobalFlagStore(&gameState.globalInts); - } - // Wire tutorial advance callbacks. // completeTutorialStep() only acts on the current step, so sharing is safe. { loc->dialogueSystem.setOnDialogueAdvanced([this]() { - gameState.completeTutorialStep(TutorialStep::AdvanceDialogue); + internalGameServer.submit( + CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue}); }); loc->onPlayerFloorWalk = [this]() { - gameState.completeTutorialStep(TutorialStep::Walk); + internalGameServer.submit( + CompleteTutorialStepCommand{TutorialStep::Walk}); menuManager.onPlayerStartedWalking(); }; } @@ -897,19 +823,23 @@ namespace FRG } bool currentTargetDead = false; + auto playerState = loc->player->stateSnapshot(); + if (!playerState) return; for (const auto& npc : loc->npcs) { - if (npc && npc->entityId() == loc->player->characterState.attackTargetId) { - currentTargetDead = npc->characterState.hp <= 0.f; + if (npc && npc->entityId() == playerState->attackTargetId) { + auto npcState = npc->stateSnapshot(); + currentTargetDead = !npcState || npcState->hp <= 0.f; break; } } - if ((loc->player->characterState.attackTargetId.empty() || currentTargetDead) + if ((playerState->attackTargetId.empty() || currentTargetDead) && loc->player->isMoving() == false && loc->player->getHp() > 0) { internalGameServer.submit(SetCharacterTargetCommand{ - loc->locationId(), "player", attackerId}); + loc->locationId(), + internalGameServer.localPlayerId(), attackerId}); } } @@ -931,7 +861,9 @@ namespace FRG CheckGlError(__FILE__, __LINE__); logger() << "Shadow map initialized" << std::endl; - for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap = shadowMap; + for (const std::string& id : internalGameServer.loadedRuntimeLocationIds()) + if (Location* loc = internalGameServer.runtimeLocation(id)) + loc->shadowMap = shadowMap; } void Game::destroyShadowMapAndProparateToLocations() @@ -941,7 +873,9 @@ namespace FRG shadowMap.reset(); CheckGlError(__FILE__, __LINE__); - for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap.reset(); + for (const std::string& id : internalGameServer.loadedRuntimeLocationIds()) + if (Location* loc = internalGameServer.runtimeLocation(id)) + loc->shadowMap.reset(); CheckGlError(__FILE__, __LINE__); } @@ -1014,16 +948,12 @@ namespace FRG Location* loc = currentLocation(); if (loc) { - // Sync global flags so Location's draw functions see them. - loc->state.isDarklands = gameState.isDarklands; - loc->state.isNight = gameState.isNight; - loc->state.isDawn = gameState.isDawn; - - if (gameState.isDarklands) { + const auto state = stateSnapshot(); + if (state && state->isDarklands) { loc->drawGameDarklands(); CheckGlError(__FILE__, __LINE__); } - else if (gameState.isNight) { + else if (state && state->isNight) { loc->drawGameNight(); CheckGlError(__FILE__, __LINE__); } @@ -1206,11 +1136,67 @@ namespace FRG // The authoritative implementation advances simulation; a future // network client can use the same call to poll/apply server updates. internalGameServer.update(delta); + processServerEvents(); } } } + void Game::processServerEvents() + { + for (GameServerEvent& event : internalGameServer.drainEvents()) { + std::visit([this](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + menuManager.onItemPickedUp(value.itemId); + const Item* item = + ItemRegistry::instance().findById(value.itemId); + if (item) + menuManager.showToast( + menuManager.toastTextureItemAdded, item->name); + audioPlayer->playSoundAsync( + "resources/audio/831642__ienba__generic-game-pick-up.ogg"); + } else if constexpr (std::is_same_v) { + const Item* item = + ItemRegistry::instance().findById(value.itemId); + if (item) + menuManager.showToast( + menuManager.toastTextureItemRemoved, item->name); + audioPlayer->playSoundAsync( + "resources/audio/831642__ienba__generic-game-pick-up.ogg"); + } else if constexpr (std::is_same_v) { + const auto state = stateSnapshot(); + if (!state) return; + auto quest = std::find_if( + state->quests.begin(), state->quests.end(), + [&value](const Quest::QuestState& candidate) { + return candidate.definition.id == value.questId; + }); + if (quest == state->quests.end()) return; + switch (value.type) { + case Quest::QuestEventType::Unlocked: + menuManager.showToast( + menuManager.toastTextureQuestNew, + quest->definition.title); + break; + case Quest::QuestEventType::Completed: + menuManager.showToast( + menuManager.toastTextureQuestCompleted, + quest->definition.title); + break; + case Quest::QuestEventType::Failed: + menuManager.showToast( + menuManager.toastTextureQuestFailed, + quest->definition.title); + break; + case Quest::QuestEventType::ObjectiveCompleted: + break; + } + } + }, event); + } + } + void Game::render() { FRG::CheckGlError(__FILE__, __LINE__); @@ -1256,10 +1242,7 @@ namespace FRG } void Game::update() { - // Host systems (locations, scripts and loading) still execute in-process. - // Publish any changes they made since the previous frame as a new immutable - // revision before presentation clients query the server. - internalGameServer.markHostMutation(); + processServerEvents(); SDL_Event event; const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height); while (SDL_PollEvent(&event)) { @@ -1394,7 +1377,8 @@ namespace FRG } //logger() << "Current zoom: " << Environment::zoom << std::endl; // Tutorial: any mouse-wheel scroll counts as the "zoom gesture". - gameState.completeTutorialStep(TutorialStep::Zoom); + internalGameServer.submit( + CompleteTutorialStepCommand{TutorialStep::Zoom}); } if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { @@ -1422,7 +1406,6 @@ namespace FRG if (event.key.keysym.sym == SDLK_3) { currentLocation()->switchNavigation(2); - //logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl; } if (event.key.keysym.sym == SDLK_n) { @@ -1435,8 +1418,6 @@ namespace FRG { currentLocation()->editorMode = EditorMode::None; } - - //logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl; } } @@ -1516,8 +1497,11 @@ namespace FRG currentLocation()->lastMouseY = eventY; // Snapshot current angles so we can measure how far the user rotates. - dragStartAzimuth = currentLocation()->state.cameraAzimuth; - dragStartInclination = currentLocation()->state.cameraInclination; + auto state = currentLocation()->stateSnapshot(); + if (state) { + dragStartAzimuth = state->cameraAzimuth; + dragStartInclination = state->cameraInclination; + } } } @@ -1577,7 +1561,8 @@ namespace FRG // Tutorial: detect a significant pinch-zoom (≥ 2 zoom units). if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) { - gameState.completeTutorialStep(TutorialStep::Zoom); + internalGameServer.submit( + CompleteTutorialStepCommand{TutorialStep::Zoom}); } } @@ -1720,12 +1705,17 @@ namespace FRG // ~0.15 rad (≈8.6°) per axis ensures the user intentionally panned in 2D, // not just nudged a single axis by accident. static constexpr float TUTORIAL_ROTATION_THRESHOLD = 0.15f; + const auto game = stateSnapshot(); if (cameraDragging - && gameState.tutorialStep == TutorialStep::RotateCamera) { - float deltaAz = std::abs(currentLocation()->state.cameraAzimuth - dragStartAzimuth); - float deltaInc = std::abs(currentLocation()->state.cameraInclination - dragStartInclination); + && game + && game->tutorialStep == TutorialStep::RotateCamera) { + auto state = currentLocation()->stateSnapshot(); + if (!state) return; + float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth); + float deltaInc = std::abs(state->cameraInclination - dragStartInclination); if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) { - gameState.completeTutorialStep(TutorialStep::RotateCamera); + internalGameServer.submit( + CompleteTutorialStepCommand{TutorialStep::RotateCamera}); } } } @@ -1733,13 +1723,14 @@ namespace FRG bool Game::startDarklandsTransition() { - if (!gameState.isNight) + const auto state = stateSnapshot(); + if (!state || !state->isNight) { currentLocation()->dialogueSystem.startDialogue("darklands_day_dialog001"); return false; } - if (gameState.isDawn) + if (state->isDawn) { currentLocation()->dialogueSystem.startDialogue("darklands_morning_dialog001"); return false; @@ -1773,11 +1764,13 @@ namespace FRG if (darklandsFlashFadingIn) { darklandsFlashAlpha = min(darklandsFlashAlpha + step, 1.0f); if (darklandsFlashAlpha >= 1.0f) { + const auto before = stateSnapshot(); + if (!before) return; if (isNightTransition) { - gameState.isNight = !gameState.isNight; + internalGameServer.submit(SetWorldModeCommand{ + before->isDarklands, !before->isNight, before->isDawn}); if (currentLocation()) { - currentLocation()->state.isNight = gameState.isNight; currentLocation()->scriptEngine.callTriggerNightEnterCallback(); } @@ -1785,12 +1778,15 @@ namespace FRG menuManager.lastMusicTrack = "music/bishkek night.ogg"; } else { - gameState.isDarklands = !gameState.isDarklands; + internalGameServer.submit(SetWorldModeCommand{ + !before->isDarklands, before->isNight, before->isDawn}); + const auto after = stateSnapshot(); + if (!after) return; // Change HUD - menuManager.setDarklandsMode(gameState.isDarklands); + menuManager.setDarklandsMode(after->isDarklands); - if (gameState.isDarklands) + if (after->isDarklands) { audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; @@ -1802,8 +1798,7 @@ namespace FRG } if (currentLocation()) { - currentLocation()->state.isDarklands = gameState.isDarklands; - if (gameState.isDarklands) + if (after->isDarklands) currentLocation()->scriptEngine.callDarklandsEnterCallback(); else currentLocation()->scriptEngine.callDarklandsExitCallback(); @@ -1935,19 +1930,21 @@ namespace FRG void Game::playMusicForLoadedGame() { - if (gameState.isDarklands) + const auto state = stateSnapshot(); + if (!state) return; + if (state->isDarklands) { audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; } - else if (gameState.isNight) + else if (state->isNight) { audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg"); menuManager.lastMusicTrack = "music/bishkek night.ogg"; } else { - if (gameState.currentLocationName == "location_dorm") { + if (state->currentLocationId == "location_dorm") { audioPlayer->crossFadeMusicAsync("music/obshaga.ogg"); menuManager.lastMusicTrack = "music/obshaga.ogg"; } diff --git a/src/Game.h b/src/Game.h index bf43f9b..b963ac1 100644 --- a/src/Game.h +++ b/src/Game.h @@ -21,7 +21,6 @@ #include #include "Location.h" #include "AudioPlayerAsync.h" -#include "GameState.h" #include "game_server/InternalGameServer.h" namespace FRG { @@ -84,9 +83,6 @@ namespace FRG { private: InternalGameServer internalGameServer; - // Host-only alias while location/script orchestration is moved behind typed - // commands. The referenced object is owned by internalGameServer. - GameState& gameState; public: IGameServer& server() { return internalGameServer; } @@ -132,6 +128,9 @@ namespace FRG { Location* currentLocation() const; + std::shared_ptr stateSnapshot() const { + return internalGameServer.snapshot(); + } void saveGame(int slot); void loadGame(int slot); @@ -181,6 +180,7 @@ namespace FRG { int64_t getSyncTimeMs(); void processTickCount(); + void processServerEvents(); void drawScene(); void drawUI(); diff --git a/src/GameState.cpp b/src/GameState.cpp index e2de921..dca64a1 100644 --- a/src/GameState.cpp +++ b/src/GameState.cpp @@ -215,6 +215,7 @@ void GameState::load(const nlohmann::json& in) auto& player = players[id]; if (!player) player = std::make_shared(); player->load(value); + player->configureAsPlayer(); } for (auto it = players.begin(); it != players.end();) { if (!playerValues.contains(it->first)) it = players.erase(it); diff --git a/src/Location.cpp b/src/Location.cpp index b5fad04..8a73d02 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -53,17 +53,69 @@ namespace FRG return (p2 - closest).norm(); } - Location::Location(Renderer& iRenderer, Inventory& iInventory, LocationState& boundState, - IGameServer& server, std::string locationId) + Location::Location(Renderer& iRenderer, IGameServer& server, + std::string locationId) : renderer(iRenderer) - , inventory(iInventory) , editor(*this) - , state(boundState) , gameServer_(server) , locationId_(std::move(locationId)) { } + std::shared_ptr Location::stateSnapshot() const + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot) return {}; + auto location = snapshot->locations.find(locationId_); + if (location == snapshot->locations.end()) return {}; + return std::shared_ptr( + std::move(snapshot), &location->second.state); + } + + bool Location::isDarklands() const + { + auto snapshot = gameServer_.snapshot(); + return snapshot && snapshot->isDarklands; + } + + bool Location::isNight() const + { + auto snapshot = gameServer_.snapshot(); + return snapshot && snapshot->isNight; + } + + bool Location::isDawn() const + { + auto snapshot = gameServer_.snapshot(); + return snapshot && snapshot->isDawn; + } + + int Location::globalInt(const std::string& key) const + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot) return 0; + auto it = snapshot->globalInts.find(key); + return it != snapshot->globalInts.end() ? it->second : 0; + } + + float Location::globalFloat(const std::string& key) const + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot) return 0.f; + auto it = snapshot->globalFloats.find(key); + return it != snapshot->globalFloats.end() ? it->second : 0.f; + } + + void Location::setGlobalInt(const std::string& key, int value) + { + gameServer_.submit(SetGlobalIntCommand{key, value}); + } + + void Location::setGlobalFloat(const std::string& key, float value) + { + gameServer_.submit(SetGlobalFloatCommand{key, value}); + } + Location::~Location() { // The player is shared with the next location and outlives this one: drop @@ -84,7 +136,9 @@ namespace FRG Character& character, const std::function& mutation) { - CharacterState stateCopy = character.characterState; + auto snapshot = character.stateSnapshot(); + if (!snapshot) return; + CharacterState stateCopy = *snapshot; mutation(stateCopy); gameServer_.submit(CommitCharacterStateCommand{ locationId_, character.entityId(), std::move(stateCopy)}); @@ -128,16 +182,132 @@ namespace FRG { gameServer_.submit( SetCharacterHealthCommand{locationId_, character.entityId(), hp}); - if (character.onHpChanged) + auto state = character.stateSnapshot(); + if (character.onHpChanged && state) character.onHpChanged( - character.characterState.hp, - character.characterState.initialHp, + state->hp, + state->initialHp, std::string()); } + bool Location::hasInventoryItem(const std::string& itemId) const + { + auto snapshot = gameServer_.snapshot(); + return snapshot && + std::find( + snapshot->inventoryItemIds.begin(), + snapshot->inventoryItemIds.end(), + itemId) != snapshot->inventoryItemIds.end(); + } + + std::size_t Location::inventoryItemCount() const + { + auto snapshot = gameServer_.snapshot(); + return snapshot ? snapshot->inventoryItemIds.size() : 0; + } + + void Location::addInventoryItem(std::string itemId) + { + gameServer_.submit(AddInventoryItemCommand{std::move(itemId)}); + } + + void Location::removeInventoryItem(std::string itemId) + { + gameServer_.submit(RemoveInventoryItemCommand{std::move(itemId)}); + } + + namespace { + const nlohmann::json* findQuestProgress( + const GameSnapshot& snapshot, const std::string& questId) + { + if (!snapshot.questJournal.contains("quests") || + !snapshot.questJournal["quests"].is_array()) return nullptr; + for (const auto& quest : snapshot.questJournal["quests"]) + if (quest.value("id", std::string()) == questId) return ? + return nullptr; + } + + bool hasQuestObjective( + const nlohmann::json& quest, const std::string& objectiveId) + { + if (!quest.contains("objectives") || !quest["objectives"].is_array()) + return false; + for (const auto& objective : quest["objectives"]) + if (objective.value("id", std::string()) == objectiveId) return true; + return false; + } + } + + bool Location::unlockQuest(const std::string& questId) + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot || !findQuestProgress(*snapshot, questId)) return false; + gameServer_.submit(UnlockQuestCommand{questId}); + return true; + } + + bool Location::completeQuest(const std::string& questId) + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot || !findQuestProgress(*snapshot, questId)) return false; + gameServer_.submit(CompleteQuestCommand{questId}); + return true; + } + + bool Location::failQuest(const std::string& questId) + { + auto snapshot = gameServer_.snapshot(); + if (!snapshot || !findQuestProgress(*snapshot, questId)) return false; + gameServer_.submit(FailQuestCommand{questId}); + return true; + } + + bool Location::setQuestObjectiveCompleted( + const std::string& questId, const std::string& objectiveId, bool completed) + { + auto snapshot = gameServer_.snapshot(); + const auto* quest = snapshot + ? findQuestProgress(*snapshot, questId) + : nullptr; + if (!quest || !hasQuestObjective(*quest, objectiveId)) return false; + gameServer_.submit(SetQuestObjectiveCompletedCommand{ + questId, objectiveId, completed}); + return true; + } + + bool Location::setQuestObjectiveVisible( + const std::string& questId, const std::string& objectiveId, bool visible) + { + auto snapshot = gameServer_.snapshot(); + const auto* quest = snapshot + ? findQuestProgress(*snapshot, questId) + : nullptr; + if (!quest || !hasQuestObjective(*quest, objectiveId)) return false; + gameServer_.submit(SetQuestObjectiveVisibleCommand{ + questId, objectiveId, visible}); + return true; + } + + bool Location::setQuestActiveObjective( + const std::string& questId, int objectiveIndex) + { + auto snapshot = gameServer_.snapshot(); + const auto* quest = snapshot + ? findQuestProgress(*snapshot, questId) + : nullptr; + if (!quest || !quest->contains("objectives") || + !(*quest)["objectives"].is_array() || objectiveIndex < 0 || + objectiveIndex >= static_cast((*quest)["objectives"].size())) { + return false; + } + gameServer_.submit(SetQuestActiveObjectiveCommand{ + questId, objectiveIndex}); + return true; + } + // ---- Pending-target lookup ---- - TeleportZone* Location::getTargetTeleportZone() + TeleportZone* Location::getTargetTeleportZone(const LocationState& state) { if (state.targetTeleportZoneId.empty()) return nullptr; for (auto& zone : teleportZones) @@ -147,9 +317,10 @@ namespace FRG // ---- Setup ---- - void Location::setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer) + void Location::setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer) { if (!params.gameState) throw std::runtime_error("Location requires authoritative state binding"); + this->audioPlayer = audioPlayer; pathVisualization.initialize(); editor.buildNavMeshes(); @@ -180,6 +351,7 @@ namespace FRG workingState.position = params.playerPosition; }); setCharacterTarget(*player, params.playerPosition); + synchronizeRemotePlayers(); logger() << "Load resurces step 9" << std::endl; SDL_PumpEvents(); @@ -190,7 +362,8 @@ namespace FRG params.npcsJsonPath, renderer, params.gameState->npcs, gameServer_, locationId_, CONST_ZIP_FILE); for (auto& npc : npcs) { if (!npc) continue; - if (npc->characterState.canAttack) { + auto state = npc->stateSnapshot(); + if (state && state->canAttack) { npc->setupHitSparks(sparkTexture); npc->audioPlayer = audioPlayer; } @@ -209,7 +382,33 @@ namespace FRG dialogueSystem.loadCutsceneDatabase("resources/dialogue/cutscenes008.json"); dialogueSystem.loadTranslations("resources/dialogue/translate_dialogues.json"); dialogueSystem.loadCutsceneTranslations("resources/dialogue/translate_cutscenes.json"); - dialogueSystem.setQuestJournal(journal); + dialogueSystem.setGlobalFlagAccessors( + [this](const std::string& key) { return globalInt(key); }, + [this](const std::string& key, int value) { + setGlobalInt(key, value); + }); + dialogueSystem.setQuestActionHandler( + [this](const Dialogue::QuestActionRequest& request) { + switch (request.type) { + case Dialogue::QuestActionType::Unlock: + unlockQuest(request.questId); + break; + case Dialogue::QuestActionType::Complete: + completeQuest(request.questId); + break; + case Dialogue::QuestActionType::Fail: + failQuest(request.questId); + break; + case Dialogue::QuestActionType::CompleteObjective: + setQuestObjectiveCompleted( + request.questId, request.objectiveId, true); + break; + case Dialogue::QuestActionType::ShowObjective: + setQuestObjectiveVisible( + request.questId, request.objectiveId, true); + break; + } + }); SDL_PumpEvents(); @@ -219,8 +418,7 @@ namespace FRG npcNameText.reset(); } - scriptEngine.init(this, &inventory, audioPlayer, params.scriptPath); - scriptEngine.setQuestJournal(journal); + scriptEngine.init(this, audioPlayer, params.scriptPath); dialogueSystem.setOnCutsceneFinished([this](const std::string& cutsceneId) { scriptEngine.callCutsceneCompleteCallback(cutsceneId); @@ -260,7 +458,7 @@ namespace FRG } std::shared_ptr Location::createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer, - CharacterState& state, IGameServer& server) + IGameServer& server, const std::string& playerId) { //auto playerTexture = std::make_shared(CreateTextureDataFromPng("resources/w/gg/IMG_20260413_182354_992.png", CONST_ZIP_FILE)); //auto playerTexture = renderer.textureManager.LoadFromPng("resources/w/gg/UniV_Grid_2K_Base_color.png", CONST_ZIP_FILE); @@ -268,17 +466,8 @@ namespace FRG auto sparkTexture = renderer.textureManager.LoadFromPng("resources/w/spark.png", CONST_ZIP_FILE); - // Configure server-owned setup state before binding the const Character view. - state.walkSpeed = 3.0f; - state.rotationSpeed = 8.0f; - state.modelScale = 1.f; - state.modelCorrectionRotation = Eigen::Quaternionf( - Eigen::AngleAxisf(M_PI, Eigen::Vector3f::UnitY()) * - Eigen::AngleAxisf(-M_PI * 0.5, Eigen::Vector3f::UnitX())); - state.canAttack = true; - state.isPlayer = true; - - auto player = std::make_shared(state, server, std::string(), "player"); + auto player = std::make_shared( + server, std::string(), playerId); player->loadBinaryAnimation(AnimationState::STAND, "resources/w/gg/new2/gg_stand_idle001_small.anim", CONST_ZIP_FILE); player->loadBinaryAnimation(AnimationState::WALK, "resources/w/gg/new2/gg_walk001_small.anim", CONST_ZIP_FILE); @@ -308,6 +497,44 @@ namespace FRG return player; } + void Location::synchronizeRemotePlayers() + { + const auto snapshot = gameServer_.snapshot(); + if (!snapshot) return; + const std::string& localId = gameServer_.localPlayerId(); + + for (auto it = remotePlayers_.begin(); it != remotePlayers_.end();) { + if (it->first == localId || + snapshot->players.find(it->first) == snapshot->players.end()) { + it = remotePlayers_.erase(it); + } else { + ++it; + } + } + + for (const auto& [id, state] : snapshot->players) { + if (id == localId || remotePlayers_.count(id) > 0) continue; + auto remote = createPlayer(renderer, audioPlayer, gameServer_, id); + remote->bindToLocation(locationId_); + remotePlayers_.emplace(id, std::move(remote)); + } + } + + void Location::drawRemotePlayerNames( + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix) + { + if (!npcNameText) return; + for (auto& [id, remote] : remotePlayers_) { + if (!remote) continue; + const auto state = remote->stateSnapshot(); + const std::string& name = + state && !state->npcName.empty() ? state->npcName : id; + remote->drawPlayerName( + *npcNameText, cameraViewMatrix, projectionMatrix, name); + } + } + void Location::loadTeleportZones(const std::string& jsonPath, const char* zipFile) { if (jsonPath.empty()) return; @@ -506,39 +733,15 @@ namespace FRG } } - /* void Location::setupNavigation(const std::vector& paths) - { - navigationMapPaths = paths; - state.navigationMaps.clear(); - state.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(); - state.navigationMaps[i].build(paths[i], CONST_ZIP_FILE); - } - - state.activeNavigationIndex = 0; - - if (editorMode == EditorMode::Navigation) { - editor.buildNavMeshes(); - } - }*/ - bool Location::switchNavigation(int index) { - if (index < 0 || index >= static_cast(state.navigationMaps.size())) { + auto state = stateSnapshot(); + if (!state || index < 0 || + index >= static_cast(state->navigationMaps.size())) { std::cerr << "[NAV] switchNavigation: index " << index << " out of range\n"; return false; } - state.activeNavigationIndex = index; - - // Force all characters to replan their paths against the new nav map. - if (player) replanCharacter(*player); - for (auto& npc : npcs) { - if (npc) replanCharacter(*npc); - } + gameServer_.submit(SwitchLocationNavigationCommand{locationId_, index}); if (editorMode == EditorMode::Navigation) { editor.buildNavMeshes(); @@ -559,6 +762,7 @@ namespace FRG float closestDistance = FLT_MAX; InteractiveObject* closestObject = nullptr; + const bool darklands = isDarklands(); for (auto& intObj : interactiveObjects) { //logger() << "[RAYCAST] Checking object: " << intObj.loadedObject.name << " (active: " << intObj.isActive << ")" << std::endl; @@ -568,7 +772,7 @@ namespace FRG continue; } - if (state.isDarklands) { + if (darklands) { if (!intObj.loadedObject.textureDarklands) continue; } else { if (!intObj.loadedObject.texture) continue; @@ -646,13 +850,14 @@ namespace FRG //logger() << "[RAYCAST_NPC] Starting raycast with " << npcs.size() << " npcs" << std::endl; for (auto& npc : npcs) { - if (npc->getHp() <= 0.f) { + auto npcState = npc ? npc->stateSnapshot() : nullptr; + if (!npcState || npcState->hp <= 0.f) { //logger() << "[RAYCAST_NPC] " << npc->entityId() << " is dead, skipping" << std::endl; continue; } - if (!npc->characterState.enabled) continue; - const float dx = rayOrigin.x() - npc->characterState.position.x(); - const float dz = rayOrigin.z() - npc->characterState.position.z(); + if (!npcState->enabled) continue; + const float dx = rayOrigin.x() - npcState->position.x(); + const float dz = rayOrigin.z() - npcState->position.z(); const float a = rayDir.x() * rayDir.x() + rayDir.z() * rayDir.z(); if (a < 1e-6f) continue; // purely-vertical ray; not a real click case @@ -669,7 +874,7 @@ namespace FRG float exitT = tFar; // Clip against the cylinder's vertical extent. - const float yMin = npc->characterState.position.y(); + const float yMin = npcState->position.y(); const float yMax = yMin + NPC_CLICK_HEIGHT; if (std::abs(rayDir.y()) > 1e-6f) { const float tYa = (yMin - rayOrigin.y()) / rayDir.y(); @@ -730,6 +935,9 @@ namespace FRG void Location::drawGame() { + auto locationState = stateSnapshot(); + if (!locationState) return; + auto playerState = player ? player->stateSnapshot() : nullptr; glClearColor(0.53, 0.81, 0.92, 1.0f); glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); @@ -748,9 +956,10 @@ namespace FRG renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom }); //renderer.TranslateMatrix({ 0, -6.f, 0 }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + const Eigen::Vector3f camTarget = + playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -777,10 +986,14 @@ namespace FRG renderer.RenderUniform1f("uAlpha", 1.0f); const Eigen::Matrix4f currentView = renderer.GetCurrentModelViewMatrix(); if (player) player->prepareHitSparksForDraw(currentView); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->prepareHitSparksForDraw(currentView); for (auto& npc : npcs) npc->prepareHitSparksForDraw(currentView); for (auto& tz : teleportZones) tz.prepareForDraw(currentView); if (player) player->draw(renderer); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->draw(renderer); for (auto& npc : npcs) npc->draw(renderer); drawNpcCar(); @@ -806,6 +1019,7 @@ namespace FRG Eigen::Matrix4f proj = MakePerspectiveMatrix(1.0f / 1.5f, static_cast(Environment::width) / static_cast(Environment::height), Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); + drawRemotePlayerNames(currentView, proj); for (auto& npc : npcs) { if (npc) npc->drawName(*npcNameText, currentView, proj); } @@ -820,7 +1034,9 @@ namespace FRG { if (!shadowMap) return; - const Eigen::Vector3f& sceneCenter = player ? player->characterState.position : Eigen::Vector3f::Zero(); + auto playerState = player ? player->stateSnapshot() : nullptr; + const Eigen::Vector3f sceneCenter = + playerState ? playerState->position : Eigen::Vector3f::Zero(); shadowMap->updateLightSpaceMatrix(sceneCenter); shadowMap->bind(); @@ -876,6 +1092,8 @@ namespace FRG // Draw characters (they handle their own skinning shader switch internally) if (player) player->drawShadowDepth(renderer); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->drawShadowDepth(renderer); for (auto& npc : npcs) npc->drawShadowDepth(renderer); drawNpcCar(); @@ -892,6 +1110,8 @@ namespace FRG void Location::drawGameWithShadows() { + auto locationState = stateSnapshot(); + if (!locationState) return; glClearColor(0.53, 0.81, 0.92, 1.0f); glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); @@ -930,9 +1150,11 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + auto playerState = player ? player->stateSnapshot() : nullptr; + const Eigen::Vector3f camTarget = + playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -985,9 +1207,13 @@ namespace FRG { const Eigen::Matrix4f currentView = renderer.GetCurrentModelViewMatrix(); if (player) player->prepareHitSparksForDraw(currentView); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->prepareHitSparksForDraw(currentView); for (auto& npc : npcs) npc->prepareHitSparksForDraw(currentView); } if (player) player->draw(renderer); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->draw(renderer); for (auto& npc : npcs) npc->draw(renderer); drawNpcCar(); #else @@ -995,12 +1221,17 @@ namespace FRG CheckGlError(__FILE__, __LINE__); if (player) player->prepareHitSparksForDraw(cameraViewMatrix); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->prepareHitSparksForDraw(cameraViewMatrix); for (auto& npc : npcs) npc->prepareHitSparksForDraw(cameraViewMatrix); for (auto& tz : teleportZones) tz.prepareForDraw(cameraViewMatrix); if (player) player->drawWithShadow(renderer, lightFromCamera, shadowMap->getDepthTexture(), lightDirCamera); CheckGlError(__FILE__, __LINE__); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->drawWithShadow( + renderer, lightFromCamera, shadowMap->getDepthTexture(), lightDirCamera); for (auto& npc : npcs) npc->drawWithShadow(renderer, lightFromCamera, shadowMap->getDepthTexture(), lightDirCamera); drawNpcCar(); @@ -1027,6 +1258,7 @@ namespace FRG Eigen::Matrix4f proj = MakePerspectiveMatrix(1.0f / 1.5f, static_cast(Environment::width) / static_cast(Environment::height), Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); + drawRemotePlayerNames(cameraViewMatrix, proj); for (auto& npc : npcs) { if (npc) npc->drawName(*npcNameText, cameraViewMatrix, proj); } @@ -1039,6 +1271,9 @@ namespace FRG void Location::drawGameDarklands() { + auto locationState = stateSnapshot(); + if (!locationState) return; + auto playerState = player ? player->stateSnapshot() : nullptr; glClearColor(0.05f, 0.05f, 0.2f, 1.0f); glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); @@ -1055,9 +1290,10 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + const Eigen::Vector3f camTarget = + playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1081,10 +1317,14 @@ namespace FRG renderer.RenderUniform1f("uAlpha", 1.0f); const Eigen::Matrix4f currentView = renderer.GetCurrentModelViewMatrix(); if (player) player->prepareHitSparksForDraw(currentView); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->prepareHitSparksForDraw(currentView); for (auto& npc : npcs) npc->prepareHitSparksForDraw(currentView); for (auto& tz : teleportZones) tz.prepareForDraw(currentView); if (player) player->draw(renderer); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->draw(renderer); for (auto& npc : npcs) npc->draw(renderer); //drawNpcCar(); @@ -1108,6 +1348,7 @@ namespace FRG Eigen::Matrix4f proj = MakePerspectiveMatrix(1.0f / 1.5f, static_cast(Environment::width) / static_cast(Environment::height), Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); + drawRemotePlayerNames(currentView, proj); for (auto& npc : npcs) { if (npc) npc->drawName(*npcNameText, currentView, proj); } @@ -1146,6 +1387,8 @@ namespace FRG } } if (player) player->drawShadowDepth(renderer); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->drawShadowDepth(renderer); for (auto& npc : npcs) npc->drawShadowDepth(renderer); drawNpcCar(); @@ -1160,10 +1403,12 @@ namespace FRG void Location::drawNpcCar() { + auto locationState = stateSnapshot(); + if (!locationState) return; renderer.PushMatrix(); - renderer.TranslateMatrix(state.taxiCar.position); + renderer.TranslateMatrix(locationState->taxiCar.position); renderer.TranslateMatrix({ 0, 0.7f - 0.25f +x, 0 }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.taxiCar.rotation, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->taxiCar.rotation, Eigen::Vector3f::UnitY())).toRotationMatrix()); glBindTexture(GL_TEXTURE_2D, carTexture->getTexID()); renderer.DrawVertexRenderStruct(carMesh); @@ -1183,10 +1428,10 @@ namespace FRG for (int i = 0; i < 4; ++i) { renderer.PushMatrix(); renderer.TranslateMatrix(wheelPositions[i]); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.taxiCar.wheelAngle, -Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->taxiCar.wheelAngle, -Eigen::Vector3f::UnitX())).toRotationMatrix()); if (isFront[i]) { - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.taxiCar.steeringAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->taxiCar.steeringAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); } renderer.DrawVertexRenderStruct(carWheelMesh); renderer.PopMatrix(); @@ -1198,14 +1443,19 @@ namespace FRG void Location::drawGameNight() { + auto locationState = stateSnapshot(); + if (!locationState) return; + auto gameSnapshot = gameServer_.snapshot(); + if (!gameSnapshot) return; + auto playerState = player ? player->stateSnapshot() : nullptr; // --- Determine which lights are active this frame --- // autoLight=true lights only activate when they are the nearest light to the player. // autoLight=false lights are always active. int nearestLightIdx = -1; - if (player && !pointLights.empty()) { + if (playerState && !pointLights.empty()) { float minDist = FLT_MAX; for (int i = 0; i < static_cast(pointLights.size()); ++i) { - const float d = (pointLights[i].position - player->characterState.position).norm(); + const float d = (pointLights[i].position - playerState->position).norm(); if (d < minDist) { minDist = d; nearestLightIdx = i; } } } @@ -1216,15 +1466,15 @@ namespace FRG for (int i = 0; i < static_cast(pointLights.size()); ++i) { const PointLight& pl = pointLights[i]; bool active = !pl.autoLight; - if (!active && player) { + if (!active && playerState) { if (pl.autoLightLimitX > 0.0f && pl.autoLightLimitY > 0.0f) { // Rectangular activation zone — independent of nearest-light heuristic - const float dx = std::abs(player->characterState.position.x() - pl.position.x()); - const float dz = std::abs(player->characterState.position.z() - pl.position.z()); + const float dx = std::abs(playerState->position.x() - pl.position.x()); + const float dz = std::abs(playerState->position.z() - pl.position.z()); active = (dx <= pl.autoLightLimitX && dz <= pl.autoLightLimitY); } else { // only the nearest autoLight activates, with optional distance cap - const float dist = (pl.position - player->characterState.position).norm(); + const float dist = (pl.position - playerState->position).norm(); active = (i == nearestLightIdx) && (pl.autoLightDistance <= 0.0f || dist <= pl.autoLightDistance); } @@ -1237,10 +1487,10 @@ namespace FRG // Shadow caster = nearest active light to player const PointLight* shadowLight = nullptr; - if (player && !activeLightIndices.empty()) { + if (playerState && !activeLightIndices.empty()) { float minDist = FLT_MAX; for (int idx : activeLightIndices) { - const float d = (pointLights[idx].position - player->characterState.position).norm(); + const float d = (pointLights[idx].position - playerState->position).norm(); if (d < minDist) { minDist = d; shadowLight = &pointLights[idx]; } } } @@ -1251,8 +1501,8 @@ namespace FRG static const float kDawnAmbient[3] = { 0.72f, 0.52f, 0.62f }; // brighter warm pink static const float kNightFogColor[3] = { 0.01f, 0.01f, 0.05f }; static const float kDawnFogColor[3] = { 0.50f, 0.44f, 0.47f }; // grey with slight pink - const float* ambientColor = state.isDawn ? kDawnAmbient : kNightAmbient; - const float* fogColor = state.isDawn ? kDawnFogColor : kNightFogColor; + const float* ambientColor = gameSnapshot->isDawn ? kDawnAmbient : kNightAmbient; + const float* fogColor = gameSnapshot->isDawn ? kDawnFogColor : kNightFogColor; if (hasShadows) { drawNightShadowDepthPass(*shadowLight); @@ -1286,9 +1536,10 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + const Eigen::Vector3f camTarget = + playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1378,6 +1629,8 @@ namespace FRG renderer.RenderUniform1f("uAlpha", 1.0f); if (player) player->prepareHitSparksForDraw(cameraViewMatrix); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->prepareHitSparksForDraw(cameraViewMatrix); for (auto& npc : npcs) npc->prepareHitSparksForDraw(cameraViewMatrix); for (auto& tz : teleportZones) tz.prepareForDraw(cameraViewMatrix); @@ -1385,12 +1638,21 @@ namespace FRG if (player) player->drawNightWithShadow(renderer, plPosEye, plDirEye, plColors, lightCount, plWorldXZ, plLimitXZ, cameraViewInverse, lightFromCamera, shadowMap->getDepthTexture(), ambientColor, fogColor); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->drawNightWithShadow( + renderer, plPosEye, plDirEye, plColors, lightCount, + plWorldXZ, plLimitXZ, cameraViewInverse, + lightFromCamera, shadowMap->getDepthTexture(), ambientColor, fogColor); for (auto& npc : npcs) npc->drawNightWithShadow(renderer, plPosEye, plDirEye, plColors, lightCount, plWorldXZ, plLimitXZ, cameraViewInverse, lightFromCamera, shadowMap->getDepthTexture(), ambientColor, fogColor); } else { if (player) player->drawNight(renderer, plPosEye, plDirEye, plColors, lightCount, plWorldXZ, plLimitXZ, cameraViewInverse, ambientColor, fogColor); + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->drawNight( + renderer, plPosEye, plDirEye, plColors, lightCount, + plWorldXZ, plLimitXZ, cameraViewInverse, ambientColor, fogColor); for (auto& npc : npcs) npc->drawNight(renderer, plPosEye, plDirEye, plColors, lightCount, plWorldXZ, plLimitXZ, cameraViewInverse, ambientColor, fogColor); } @@ -1416,6 +1678,7 @@ namespace FRG Eigen::Matrix4f proj = MakePerspectiveMatrix(1.0f / 1.5f, static_cast(Environment::width) / static_cast(Environment::height), Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); + drawRemotePlayerNames(cameraViewMatrix, proj); for (auto& npc : npcs) { if (npc) npc->drawName(*npcNameText, cameraViewMatrix, proj); } @@ -1428,41 +1691,39 @@ namespace FRG bool Location::setNavigationAreaAvailable(const std::string& areaName, bool available) { - return state.navigationMaps[state.activeNavigationIndex].setAreaAvailable(areaName, available); - } - - /* - void Location::fireBumpCallbacks(Character* mover, Character* standing) - { - if (mover->characterState.isPlayer) { - // Player walked into a standing NPC - const int idx = findNpcIndex(standing); - if (idx < 0) return; - auto& cooldown = npcBumpedByPlayerCooldown[idx]; - if (cooldown <= 0.f) { - cooldown = NPC_BUMP_CALLBACK_COOLDOWN; - scriptEngine.callNpcBumpedByPlayerCallback(idx); - } - } else if (standing->characterState.isPlayer) { - // Moving NPC walked into the standing player - const int idx = findNpcIndex(mover); - if (idx < 0) return; - auto& cooldown = npcBumpsPlayerCooldown[idx]; - if (cooldown <= 0.f) { - cooldown = NPC_BUMP_CALLBACK_COOLDOWN; - scriptEngine.callNpcBumpsPlayerCallback(idx); - } + auto state = stateSnapshot(); + if (!state || state->activeNavigationIndex < 0 || + state->activeNavigationIndex >= static_cast(state->navigationMaps.size())) { + return false; } - }*/ + const auto& areas = + state->navigationMaps[state->activeNavigationIndex].getAreas(); + const bool found = std::any_of( + areas.begin(), areas.end(), + [&](const PathFinder::NavigationArea& area) { + return area.name == areaName; + }); + if (!found) return false; + gameServer_.submit(SetNavigationAreaAvailableCommand{ + locationId_, areaName, available}); + return true; + } void Location::nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom) { - if (!standing || standing->characterState.isPlayer) return; - if (state.navigationMaps[state.activeNavigationIndex].isReady()) return; + auto standingState = standing ? standing->stateSnapshot() : nullptr; + if (!standingState || standingState->isPlayer) return; + auto locationState = stateSnapshot(); + if (!locationState || locationState->activeNavigationIndex < 0 || + locationState->activeNavigationIndex >= + static_cast(locationState->navigationMaps.size())) return; + const PathFinder& navigation = + locationState->navigationMaps[locationState->activeNavigationIndex]; + if (navigation.isReady()) return; static constexpr float kNudgeDist = 1.2f; - Eigen::Vector3f dir = standing->characterState.position - awayFrom; + Eigen::Vector3f dir = standingState->position - awayFrom; dir.y() = 0.f; if (dir.norm() < 1e-3f) dir = Eigen::Vector3f(1.f, 0.f, 0.f); else dir.normalize(); @@ -1470,9 +1731,9 @@ namespace FRG const float angles[] = { 0.f, static_cast(M_PI * 0.5), static_cast(-M_PI * 0.5), static_cast(M_PI) }; for (float angle : angles) { Eigen::Vector3f rotated = Eigen::AngleAxisf(angle, Eigen::Vector3f::UnitY()) * dir; - Eigen::Vector3f candidate = standing->characterState.position + rotated * kNudgeDist; + Eigen::Vector3f candidate = standingState->position + rotated * kNudgeDist; candidate.y() = 0.f; - if (state.navigationMaps[state.activeNavigationIndex].isWalkable(candidate)) { + if (navigation.isWalkable(candidate)) { setCharacterTarget(*standing, candidate); return; } @@ -1482,6 +1743,11 @@ namespace FRG void Location::update(int64_t delta, const LocationSimulationEvents& events) { pathVisualization.update(delta); + synchronizeRemotePlayers(); + auto locationState = stateSnapshot(); + if (!locationState) return; + auto gameSnapshot = gameServer_.snapshot(); + if (!gameSnapshot) return; if (events.taxiArrived) scriptEngine.callCallTaxiCallback(); @@ -1509,7 +1775,9 @@ namespace FRG } auto findRuntimeCharacter = [&](const std::string& id) -> Character* { - if (id == "player") return player.get(); + if (id == gameServer_.localPlayerId()) return player.get(); + auto remote = remotePlayers_.find(id); + if (remote != remotePlayers_.end()) return remote->second.get(); for (auto& npc : npcs) if (npc && npc->entityId() == id) return npc.get(); return nullptr; @@ -1541,10 +1809,13 @@ namespace FRG }; if (player) { - player->update(delta, eventFor("player")); + player->update(delta, eventFor(gameServer_.localPlayerId())); dialogueSystem.update(static_cast(delta)); - updateTriggerZones(player->characterState.position); + auto playerState = player->stateSnapshot(); + if (playerState) updateTriggerZones(playerState->position); } + for (auto& [id, remote] : remotePlayers_) + if (remote) remote->update(delta, eventFor(id)); bool npcAttacking = false; @@ -1552,14 +1823,16 @@ namespace FRG { npc->update(delta, eventFor(npc->entityId())); - if (npc->characterState.enabled && npc->characterState.canAttack && (!npc->characterState.pathWaypoints.empty() || npc->characterState.battle_state != 0) && npc->getHp() > 0.f) + auto npcState = npc->stateSnapshot(); + if (npcState && npcState->enabled && npcState->canAttack && + (!npcState->pathWaypoints.empty() || npcState->battle_state != 0) && + npcState->hp > 0.f) { - //logger() << "[NPC] NPC '" << npc->entityId() << "' is attacking target '" << npc->characterState.attackTargetId << "'" << std::endl; npcAttacking = true; } } - if (state.isDarklands) + if (gameSnapshot->isDarklands) { if (npcAttacking && !playBattleMusic) { @@ -1575,21 +1848,26 @@ namespace FRG for (auto& tz : teleportZones) tz.update(static_cast(delta)); - if (auto* tz = getTargetTeleportZone(); tz && player) { - float dist = (player->characterState.position - tz->position).norm(); + auto playerState = player ? player->stateSnapshot() : nullptr; + if (auto* tz = getTargetTeleportZone(*locationState); tz && playerState) { + float dist = (playerState->position - tz->position).norm(); if (dist <= tz->radius) { logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl; + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, + locationState->targetInteractiveObjectId, + locationState->targetInteractNpcId, + std::string()}); if (onTeleport) onTeleport(tz->destinationLocation, tz->destinationPosition, tz->destinationRotationY); - state.targetTeleportZoneId.clear(); return; } } - if (player) { + if (playerState) { for (auto& tz : teleportZones) { if (!tz.active || !tz.automatic) continue; const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius; - const float dist = (player->characterState.position - tz.position).norm(); + const float dist = (playerState->position - tz.position).norm(); if (!tz.automaticInitialized) { // If the player starts inside the zone (e.g. just arrived here via teleport), @@ -1621,7 +1899,11 @@ namespace FRG return; } - const std::string lastPlayerAttackTarget = player->characterState.attackTargetId; + auto playerState = player ? player->stateSnapshot() : nullptr; + if (!playerState) return; + auto locationState = stateSnapshot(); + if (!locationState) return; + const std::string lastPlayerAttackTarget = playerState->attackTargetId; clearCharacterAttackTarget(*player); @@ -1631,13 +1913,13 @@ namespace FRG float aspect = (float)Environment::width / (float)Environment::height; float tanHalfFov = tan(CAMERA_FOV_Y * 0.5f); - float cosAzim = cos(state.cameraAzimuth), sinAzim = sin(state.cameraAzimuth); - float cosIncl = cos(state.cameraInclination), sinIncl = sin(state.cameraInclination); + float cosAzim = cos(locationState->cameraAzimuth), sinAzim = sin(locationState->cameraAzimuth); + float cosIncl = cos(locationState->cameraInclination), sinIncl = sin(locationState->cameraInclination); Eigen::Vector3f camRight(cosAzim, 0.f, sinAzim); Eigen::Vector3f camForward(sinAzim * cosIncl, -sinIncl, -cosAzim * cosIncl); Eigen::Vector3f camUp(sinAzim * sinIncl, cosIncl, -cosAzim * sinIncl); - const Eigen::Vector3f& playerPos = player ? player->characterState.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f playerPos = playerState->position; Eigen::Vector3f camPos = playerPos + Eigen::Vector3f(-sinAzim * cosIncl, sinIncl, cosAzim * cosIncl) * Environment::zoom + Eigen::Vector3f(0,1,0); Eigen::Vector3f rayDir = (camForward + camRight * (ndcX * aspect * tanHalfFov) + camUp * (ndcY * tanHalfFov)).normalized(); @@ -1673,19 +1955,15 @@ namespace FRG InteractiveObject* clickedObject = raycastInteractiveObjects(camPos, rayDir); if (clickedObject && player && clickedObject->state.isActive) { //logger() << "[CLICK] *** SUCCESS: Clicked on interactive object: " << clickedObject->loadedObject.name << " ***" << std::endl; - //logger() << "[CLICK] Object position: (" << clickedObject->characterState.position.x() << ", " - // << clickedObject->characterState.position.y() << ", " << clickedObject->characterState.position.z() << ")" << std::endl; - //logger() << "[CLICK] Player position: (" << player->characterState.position.x() << ", " - // << player->characterState.position.y() << ", " << player->characterState.position.z() << ")" << std::endl; - state.targetInteractiveObjectId = clickedObject->entityId(); - state.targetInteractNpcId.clear(); - state.targetTeleportZoneId.clear(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, clickedObject->entityId(), std::string(), std::string()}); const Eigen::Vector3f interactionTarget = clickedObject->state.hasInteractionPosition ? clickedObject->state.interactionPosition : clickedObject->state.position; setCharacterTarget(*player, interactionTarget); - if (!player->characterState.pathWaypoints.empty()) { + auto updatedPlayerState = player->stateSnapshot(); + if (updatedPlayerState && !updatedPlayerState->pathWaypoints.empty()) { pathVisualization.start(*player, interactionTarget); } clearCharacterAttackTarget(*player); @@ -1697,7 +1975,9 @@ namespace FRG // Check if we clicked on an NPC Character* clickedNpc = raycastNpcs(camPos, rayDir); if (clickedNpc && player) { - float distance = (player->characterState.position - clickedNpc->characterState.position).norm(); + auto clickedNpcState = clickedNpc->stateSnapshot(); + if (!clickedNpcState) return; + float distance = (playerState->position - clickedNpcState->position).norm(); int npcIndex = -1; for (size_t i = 0; i < npcs.size(); ++i) { if (npcs[i].get() == clickedNpc) { @@ -1707,12 +1987,9 @@ namespace FRG } if (npcIndex != -1) { //logger() << "Raycast npc step 2" << std::endl; - state.targetInteractiveObjectId.clear(); - state.targetTeleportZoneId.clear(); - if (clickedNpc->characterState.canAttack) { + if (clickedNpcState->canAttack) { //logger() << "Raycast npc step 3" << std::endl; - //logger() << "player target=" << player->characterState.attackTargetId << std::endl; //logger() << "npcIndex=" << npcIndex << std::endl; // Hostile NPC: combat logic walks the player toward the stable target ID. if (lastPlayerAttackTarget == clickedNpc->entityId()) @@ -1724,19 +2001,21 @@ namespace FRG updateCharacterState(*player, [targetId](CharacterState& workingState) { workingState.attackTargetId = targetId; }); - state.targetInteractNpcId.clear(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, std::string(), std::string(), std::string()}); if (distance >= Character::kAttackRange) { - setCharacterTarget(*player, clickedNpc->characterState.position); - if (!player->characterState.pathWaypoints.empty()) { + setCharacterTarget(*player, clickedNpcState->position); + auto updatedPlayerState = player->stateSnapshot(); + if (updatedPlayerState && !updatedPlayerState->pathWaypoints.empty()) { pathVisualization.start( *player, - clickedNpc->characterState.position, + clickedNpcState->position, PathVisualization::Style::Hostile); } } - if (distance <= clickedNpc->characterState.interactionRadius) { + if (distance <= clickedNpcState->interactionRadius) { //logger() << "[CLICK] Hostile NPC " << npcIndex << " in range; firing on_npc_interact" << std::endl; scriptEngine.callNpcInteractCallback(clickedNpc->entityId()); } @@ -1748,21 +2027,25 @@ namespace FRG if (distance <= NPC_TALK_DISTANCE) { // Already in talk range — fire immediately, stop, and face the player. //logger() << "[CLICK] *** SUCCESS: Clicked on NPC index: " << npcIndex << " (in range) ***" << std::endl; - setCharacterTarget(*player, player->characterState.position); - updateCharacterState(*npcs[npcIndex], [](CharacterState& workingState) { - workingState.faceTargetId = "player"; + setCharacterTarget(*player, playerState->position); + updateCharacterState(*npcs[npcIndex], [this](CharacterState& workingState) { + workingState.faceTargetId = gameServer_.localPlayerId(); }); scriptEngine.callNpcInteractCallback(clickedNpc->entityId()); - state.targetInteractNpcId.clear(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, std::string(), std::string(), std::string()}); } else { //logger() << "[CLICK] NPC " << npcIndex << " out of talk range (distance " << distance // << " > " << NPC_TALK_DISTANCE << "); walking to NPC..." << std::endl; - setCharacterTarget(*player, clickedNpc->characterState.position); - if (!player->characterState.pathWaypoints.empty()) { - pathVisualization.start(*player, clickedNpc->characterState.position); + setCharacterTarget(*player, clickedNpcState->position); + auto updatedPlayerState = player->stateSnapshot(); + if (updatedPlayerState && !updatedPlayerState->pathWaypoints.empty()) { + pathVisualization.start(*player, clickedNpcState->position); } - state.targetInteractNpcId = clickedNpc->entityId(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, std::string(), clickedNpc->entityId(), + std::string()}); } } } @@ -1783,11 +2066,12 @@ namespace FRG if (clickedTeleport) { //logger() << "[CLICK] Clicked teleport zone '" << clickedTeleport->id << "'" << std::endl; - state.targetTeleportZoneId = clickedTeleport->id; - state.targetInteractiveObjectId.clear(); - state.targetInteractNpcId.clear(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, std::string(), std::string(), + clickedTeleport->id}); setCharacterTarget(*player, clickedTeleport->position); - if (!player->characterState.pathWaypoints.empty()) { + auto updatedPlayerState = player->stateSnapshot(); + if (updatedPlayerState && !updatedPlayerState->pathWaypoints.empty()) { pathVisualization.start( *player, clickedTeleport->position, @@ -1799,8 +2083,8 @@ namespace FRG setCharacterTarget(*player, clickedDestination); pathVisualization.start(*player, clickedDestination); clearCharacterAttackTarget(*player); - state.targetInteractNpcId.clear(); - state.targetTeleportZoneId.clear(); + gameServer_.submit(SetLocationTargetsCommand{ + locationId_, std::string(), std::string(), std::string()}); if (onPlayerFloorWalk) onPlayerFloorWalk(); } } @@ -1834,12 +2118,16 @@ namespace FRG lastMouseY = eventY; const float sensitivity = 0.005f; - state.cameraAzimuth += dx * sensitivity; - state.cameraInclination += dy * sensitivity; + auto state = stateSnapshot(); + if (!state) return; + const float azimuth = state->cameraAzimuth + dx * sensitivity; + float inclination = state->cameraInclination + dy * sensitivity; const float minInclination = M_PI * 30.f / 180.f; const float maxInclination = M_PI * 0.5f; - state.cameraInclination = max(minInclination, min(maxInclination, state.cameraInclination)); + inclination = max(minInclination, min(maxInclination, inclination)); + gameServer_.submit(SetLocationCameraCommand{ + locationId_, azimuth, inclination}); } } diff --git a/src/Location.h b/src/Location.h index 41142ea..f3185a1 100644 --- a/src/Location.h +++ b/src/Location.h @@ -72,11 +72,19 @@ namespace FRG class Location { public: - Location(Renderer& iRenderer, Inventory& iInventory, LocationState& state, IGameServer& server, + Location(Renderer& iRenderer, IGameServer& server, std::string locationId); // Detaches the (shared, longer-lived) player from this location. ~Location(); const std::string& locationId() const { return locationId_; } + std::shared_ptr stateSnapshot() const; + bool isDarklands() const; + bool isNight() const; + bool isDawn() const; + int globalInt(const std::string& key) const; + float globalFloat(const std::string& key) const; + void setGlobalInt(const std::string& key, int value); + void setGlobalFloat(const std::string& key, float value); std::unordered_map gameObjects; @@ -91,10 +99,7 @@ namespace FRG // Builds the player character (models, animations, weapon, textures). static std::shared_ptr createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer, - CharacterState& state, IGameServer& server); - - //std::vector navigationMaps; - //int state.activeNavigationIndex = 0; + IGameServer& server, const std::string& playerId); std::shared_ptr shadowMap; Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity(); @@ -160,7 +165,7 @@ namespace FRG - void setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer); + void setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer); //void setupNavigation(const std::vector& paths); bool switchNavigation(int index); void setCharacterTarget(Character& character, const Eigen::Vector3f& target, @@ -175,6 +180,18 @@ namespace FRG void resetCharacterAfterDeath(Character& character); void tryCharacterAttackCombo(Character& character); void setCharacterHp(Character& character, float hp); + bool hasInventoryItem(const std::string& itemId) const; + std::size_t inventoryItemCount() const; + void addInventoryItem(std::string itemId); + void removeInventoryItem(std::string itemId); + bool unlockQuest(const std::string& questId); + bool completeQuest(const std::string& questId); + bool failQuest(const std::string& questId); + bool setQuestObjectiveCompleted( + const std::string& questId, const std::string& objectiveId, bool completed); + bool setQuestObjectiveVisible( + const std::string& questId, const std::string& objectiveId, bool visible); + bool setQuestActiveObjective(const std::string& questId, int objectiveIndex); InteractiveObject* raycastInteractiveObjects(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir); Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f); @@ -210,15 +227,18 @@ namespace FRG friend class ScriptEngine; friend class InternalGameServer; Renderer& renderer; - Inventory& inventory; AudioPlayerAsync* audioPlayer; private: - LocationState& state; // non-owning binding to LocationGameState IGameServer& gameServer_; std::string locationId_; + std::unordered_map> remotePlayers_; + void synchronizeRemotePlayers(); + void drawRemotePlayerNames( + const Eigen::Matrix4f& cameraViewMatrix, + const Eigen::Matrix4f& projectionMatrix); // ---- Pointer-to-index helpers ---- - TeleportZone* getTargetTeleportZone(); + TeleportZone* getTargetTeleportZone(const LocationState& state); enum class InstancedTexture { None, Color, Darklands }; diff --git a/src/LocationEditor.cpp b/src/LocationEditor.cpp index 87f1153..6585b1b 100644 --- a/src/LocationEditor.cpp +++ b/src/LocationEditor.cpp @@ -1,6 +1,7 @@ #include "LocationEditor.h" #include "Location.h" #include "Character.h" +#include "game_server/IGameServer.h" #include "utils/Utils.h" #include "render/OpenGlExtensions.h" #include "TextModel.h" @@ -24,9 +25,14 @@ namespace FRG void LocationEditor::buildNavMeshes() { navigationEditorNavMeshes.clear(); - const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f; + auto state = loc.stateSnapshot(); + if (!state || state->activeNavigationIndex < 0 || + state->activeNavigationIndex >= static_cast(state->navigationMaps.size())) return; + const PathFinder& navigation = + state->navigationMaps[state->activeNavigationIndex]; + const float y = navigation.getFloorY() + 0.02f; const Eigen::Vector3f red(1.0f, 0.0f, 0.0f); - for (const auto& obs : loc.state.navigationMaps[loc.state.activeNavigationIndex].getObstaclePolygons()) { + for (const auto& obs : navigation.getObstaclePolygons()) { if (obs.polygon.size() < 3) continue; VertexRenderStruct mesh; mesh.data = CreatePolygonFloor(obs.polygon, y, red); @@ -50,7 +56,11 @@ namespace FRG { VertexDataStruct data; const Eigen::Vector3f yellow(1.0f, 1.0f, 0.0f); - const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.05f; + auto state = loc.stateSnapshot(); + if (!state || state->activeNavigationIndex < 0 || + state->activeNavigationIndex >= static_cast(state->navigationMaps.size())) return; + const float y = + state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.05f; const float s = 0.2f; for (const auto& pt : navigationEditorPoints) { @@ -126,12 +136,17 @@ namespace FRG poly.polygon.emplace_back(pt.x(), pt.z()); } - loc.state.navigationMaps[loc.state.activeNavigationIndex].addObstaclePolygon(poly); + loc.gameServer_.submit(AddNavigationObstacleCommand{ + loc.locationId_, poly.name, poly.polygon}); logger() << "[NAV_EDITOR] Added obstacle '" << poly.name << "' with " << poly.polygon.size() << " vertices\n"; { - const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f; + auto state = loc.stateSnapshot(); + if (!state || state->activeNavigationIndex < 0 || + state->activeNavigationIndex >= static_cast(state->navigationMaps.size())) return; + const float y = + state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.02f; const Eigen::Vector3f red(1.0f, 0.0f, 0.0f); VertexRenderStruct mesh; mesh.data = CreatePolygonFloor(poly.polygon, y, red); @@ -153,12 +168,21 @@ namespace FRG if (!std::filesystem::exists(baseName + ".json")) break; } - if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveConfig(baseName + ".json")) + auto state = loc.stateSnapshot(); + if (!state || state->activeNavigationIndex < 0 || + state->activeNavigationIndex >= static_cast(state->navigationMaps.size())) { + std::cerr << "[NAV_EDITOR] No active navigation map to save\n"; + return; + } + const PathFinder& navigation = + state->navigationMaps[state->activeNavigationIndex]; + + if (navigation.saveConfig(baseName + ".json")) logger() << "[NAV_EDITOR] Saved config to: " << baseName << ".json\n"; else std::cerr << "[NAV_EDITOR] Failed to save config to: " << baseName << ".json\n"; - if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveGrid(baseName + ".txt")) + if (navigation.saveGrid(baseName + ".txt")) logger() << "[NAV_EDITOR] Saved grid to: " << baseName << ".txt\n"; else std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n"; @@ -458,9 +482,11 @@ namespace FRG data.rotationX = 0.0f; data.rotationY = rotDist(rng); data.rotationZ = 0.0f; - data.positionX = loc.player->characterState.position.x(); - data.positionY = loc.player->characterState.position.y();//-0.1; - data.positionZ = loc.player->characterState.position.z(); + auto playerState = loc.player ? loc.player->stateSnapshot() : nullptr; + if (!playerState) return; + data.positionX = playerState->position.x(); + data.positionY = playerState->position.y();//-0.1; + data.positionZ = playerState->position.z(); data.scale = scaleDist(rng); GameObjectData data2; diff --git a/src/LocationState.h b/src/LocationState.h index da08f40..e9d7590 100644 --- a/src/LocationState.h +++ b/src/LocationState.h @@ -26,12 +26,6 @@ struct LocationState : public ISaveable { std::string targetInteractNpcId; std::string targetTeleportZoneId; - // ---- World-mode flags (synced from Game after each transition) ---- - bool isDarklands = false; - bool isNight = false; - bool isDawn = false; - - NpcCar taxiCarInitialState; NpcCar taxiCar; bool taxiArrivedCallbackCalled = false; diff --git a/src/ScriptEngine.cpp b/src/ScriptEngine.cpp index ba78bb5..b769d32 100644 --- a/src/ScriptEngine.cpp +++ b/src/ScriptEngine.cpp @@ -66,9 +66,6 @@ namespace FRG { std::unordered_map cutsceneCompleteCallbacks; std::unordered_map npcBumpedByPlayerCallbacks; std::unordered_map npcBumpsPlayerCallbacks; - std::unordered_map* globalInts = nullptr; - std::unordered_map* globalFloats = nullptr; - Quest::QuestJournal* questJournal = nullptr; sol::protected_function locationInitCallback; sol::protected_function locationEnterCallback; sol::protected_function locationExitCallback; @@ -82,7 +79,7 @@ namespace FRG { ScriptEngine::ScriptEngine() = default; ScriptEngine::~ScriptEngine() = default; - void ScriptEngine::init(Location* loc, Inventory* inventory, AudioPlayerAsync* audioPlayer, const std::string& scriptPath) { + void ScriptEngine::init(Location* loc, AudioPlayerAsync* audioPlayer, const std::string& scriptPath) { impl = std::make_unique(); sol::state& lua = impl->lua; @@ -199,10 +196,10 @@ namespace FRG { }); - api.set_function("has_item", [inventory](const std::string& itemId) { + api.set_function("has_item", [loc](const std::string& itemId) { const Item* item = ItemRegistry::instance().findById(itemId); if (item) { - if (inventory->hasItem(itemId)) { + if (loc->hasInventoryItem(itemId)) { logger() << "[script] has_item: " << item->name << " returns true" << std::endl; return true; } @@ -219,10 +216,10 @@ namespace FRG { }); // pickup_item(item_id) - api.set_function("pickup_item", [inventory](const std::string& itemId) { + api.set_function("pickup_item", [loc](const std::string& itemId) { const Item* item = ItemRegistry::instance().findById(itemId); if (item) { - inventory->addItem(item->id); + loc->addInventoryItem(item->id); logger() << "[script] pickup_item: " << item->name << std::endl; } else { std::cerr << "[script] pickup_item: item '" << itemId << "' not found in ItemRegistry\n"; @@ -230,9 +227,9 @@ namespace FRG { }); // remove_item(item_id) - api.set_function("remove_item", [loc, inventory](const std::string& id) { + api.set_function("remove_item", [loc](const std::string& id) { logger() << "[script] remove_item: " << id << std::endl; - inventory->removeItem(id); + loc->removeInventoryItem(id); }); api.set_function("deactivate_interactive_object", [loc](const std::string& objectId) { @@ -276,13 +273,13 @@ namespace FRG { }); // get_inventory_count() - api.set_function("get_inventory_count", [inventory]() { - return inventory->getCount(); + api.set_function("get_inventory_count", [loc]() { + return loc->inventoryItemCount(); }); // has_item(item_id) - api.set_function("has_item", [inventory](const std::string& id) { - return inventory->hasItem(id); + api.set_function("has_item", [loc](const std::string& id) { + return loc->hasInventoryItem(id); }); api.set_function("start_dialogue", @@ -381,12 +378,12 @@ namespace FRG { api.set_function("is_night", [loc]() { - return loc->state.isNight; + return loc->isNight(); }); api.set_function("is_dawn", [loc]() { - return loc->state.isDawn; + return loc->isDawn(); }); // advance_darklands_hud() @@ -415,27 +412,26 @@ namespace FRG { // is_darklands() → bool api.set_function("is_darklands", - [loc]() { return loc->state.isDarklands; }); + [loc]() { return loc->isDarklands(); }); api.set_function("setFloatValue", - [this_impl = impl.get()](const std::string& key, float value) { - if (this_impl->globalFloats) - (*this_impl->globalFloats)[key] = value; + [loc](const std::string& key, float value) { + loc->setGlobalFloat(key, value); }); api.set_function("getFloatValue", - [this_impl = impl.get()](const std::string& key) -> float { - if (!this_impl->globalFloats) return 0.0f; - auto it = this_impl->globalFloats->find(key); - return it != this_impl->globalFloats->end() ? it->second : 0.0f; + [loc](const std::string& key) -> float { + return loc->globalFloat(key); }); api.set_function("get_player_x", [loc]() -> float { - return loc->player ? loc->player->characterState.position.x() : 0.0f; + auto state = loc->player ? loc->player->stateSnapshot() : nullptr; + return state ? state->position.x() : 0.0f; }); api.set_function("get_player_z", [loc]() -> float { - return loc->player ? loc->player->characterState.position.z() : 0.0f; + auto state = loc->player ? loc->player->stateSnapshot() : nullptr; + return state ? state->position.z() : 0.0f; }); // set_player_hp(value) — sets the player's current HP directly. @@ -462,15 +458,12 @@ namespace FRG { // Global integer store — shared across all location scripts. // setIntValue(key, value) / getIntValue(key) → int (0 if not set) api.set_function("setIntValue", - [this_impl = impl.get()](const std::string& key, int value) { - if (this_impl->globalInts) - (*this_impl->globalInts)[key] = value; + [loc](const std::string& key, int value) { + loc->setGlobalInt(key, value); }); api.set_function("getIntValue", - [this_impl = impl.get()](const std::string& key) -> int { - if (!this_impl->globalInts) return 0; - auto it = this_impl->globalInts->find(key); - return it != this_impl->globalInts->end() ? it->second : 0; + [loc](const std::string& key) -> int { + return loc->globalInt(key); }); // set_darklands_callbacks(on_enter, on_exit) @@ -806,64 +799,40 @@ namespace FRG { // quest_unlock(quest_id) api.set_function("quest_unlock", - [this_impl = impl.get()](const std::string& questId) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_unlock: QuestJournal not set\n"; - return false; - } - return this_impl->questJournal->unlockQuest(questId); + [loc](const std::string& questId) -> bool { + return loc->unlockQuest(questId); }); // quest_complete(quest_id) api.set_function("quest_complete", - [this_impl = impl.get()](const std::string& questId) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_complete: QuestJournal not set\n"; - return false; - } - return this_impl->questJournal->completeQuest(questId); + [loc](const std::string& questId) -> bool { + return loc->completeQuest(questId); }); // quest_fail(quest_id) api.set_function("quest_fail", - [this_impl = impl.get()](const std::string& questId) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_fail: QuestJournal not set\n"; - return false; - } - return this_impl->questJournal->failQuest(questId); + [loc](const std::string& questId) -> bool { + return loc->failQuest(questId); }); // quest_set_objective_completed(quest_id, objective_id [, completed]) api.set_function("quest_set_objective_completed", - [this_impl = impl.get()](const std::string& questId, const std::string& objId, sol::object completed) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_set_objective_completed: QuestJournal not set\n"; - return false; - } + [loc](const std::string& questId, const std::string& objId, sol::object completed) -> bool { const bool val = completed.is() ? completed.as() : true; - return this_impl->questJournal->setObjectiveCompleted(questId, objId, val); + return loc->setQuestObjectiveCompleted(questId, objId, val); }); // quest_set_objective_visible(quest_id, objective_id [, visible]) api.set_function("quest_set_objective_visible", - [this_impl = impl.get()](const std::string& questId, const std::string& objId, sol::object visible) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_set_objective_visible: QuestJournal not set\n"; - return false; - } + [loc](const std::string& questId, const std::string& objId, sol::object visible) -> bool { const bool val = visible.is() ? visible.as() : true; - return this_impl->questJournal->setObjectiveVisible(questId, objId, val); + return loc->setQuestObjectiveVisible(questId, objId, val); }); // quest_set_active_objective(quest_id, objective_index) api.set_function("quest_set_active_objective", - [this_impl = impl.get()](const std::string& questId, int index) -> bool { - if (!this_impl->questJournal) { - std::cerr << "[script] quest_set_active_objective: QuestJournal not set\n"; - return false; - } - return this_impl->questJournal->setActiveObjective(questId, index); + [loc](const std::string& questId, int index) -> bool { + return loc->setQuestActiveObjective(questId, index); }); api.set_function("call_tutorial_taxi_required", @@ -1033,18 +1002,6 @@ namespace FRG { } } - void ScriptEngine::setGlobalStore(std::unordered_map* store) { - if (impl) impl->globalInts = store; - } - - void ScriptEngine::setGlobalFloatStore(std::unordered_map* store) { - if (impl) impl->globalFloats = store; - } - - void ScriptEngine::setQuestJournal(Quest::QuestJournal* journal) { - if (impl) impl->questJournal = journal; - } - void ScriptEngine::callLocationInitCallback() { if (!impl || !impl->locationInitCallback.valid()) return; auto result = impl->locationInitCallback(); diff --git a/src/ScriptEngine.h b/src/ScriptEngine.h index 79214a5..1692831 100644 --- a/src/ScriptEngine.h +++ b/src/ScriptEngine.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include "quest/QuestJournal.h" #include "external/nlohmann/json.hpp" #include "ISaveable.h" #include "AudioPlayerAsync.h" @@ -10,7 +8,6 @@ namespace FRG { class Location; -class Inventory; class ScriptEngine { public: @@ -18,7 +15,7 @@ public: ~ScriptEngine(); // Must be called once, after the Game's NPCs are ready. - void init(Location* game, Inventory* inventory, AudioPlayerAsync* audioPlayer, const std::string& scriptPath); + void init(Location* game, AudioPlayerAsync* audioPlayer, const std::string& scriptPath); // Execute a Lua script file. Logs errors to stderr. //void runScript(const std::string& path); @@ -37,10 +34,6 @@ public: // Fired when a moving NPC physically bumps into the standing player. void callNpcBumpsPlayerCallback(const std::string& npcId); - void setGlobalStore(std::unordered_map* store); - void setGlobalFloatStore(std::unordered_map* store); - void setQuestJournal(Quest::QuestJournal* journal); - void callLocationInitCallback(); void callLocationEnterCallback(); void callLocationExitCallback(); diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index 1ce2429..68293f1 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -162,21 +162,24 @@ void DialogueRuntime::selectChoice(int index) { } void DialogueRuntime::setFlag(const std::string& name, int value) { - if (flagStore) (*flagStore)[name] = value; + if (setGlobalFlag) setGlobalFlag(name, value); } int DialogueRuntime::getFlag(const std::string& name) const { - if (!flagStore) return 0; - auto it = flagStore->find(name); - return (it != flagStore->end()) ? it->second : 0; + return getGlobalFlag ? getGlobalFlag(name) : 0; } -void DialogueRuntime::setGlobalFlagStore(std::unordered_map* store) { - flagStore = store; +void DialogueRuntime::setGlobalFlagAccessors( + std::function getter, + std::function setter) +{ + getGlobalFlag = std::move(getter); + setGlobalFlag = std::move(setter); } -void DialogueRuntime::setQuestJournal(Quest::QuestJournal* journal) { - questJournal = journal; +void DialogueRuntime::setQuestActionHandler( + std::function handler) { + onQuestAction = std::move(handler); } void DialogueRuntime::applyQuestActions( @@ -184,17 +187,20 @@ void DialogueRuntime::applyQuestActions( const std::string& questFail, const std::string& objectiveComplete, const std::string& objectiveVisible) { - if (!questJournal) return; - if (!questUnlock.empty()) questJournal->unlockQuest(questUnlock); - if (!questComplete.empty()) questJournal->completeQuest(questComplete); - if (!questFail.empty()) questJournal->failQuest(questFail); + if (!onQuestAction) return; + if (!questUnlock.empty()) + onQuestAction({QuestActionType::Unlock, questUnlock, {}}); + if (!questComplete.empty()) + onQuestAction({QuestActionType::Complete, questComplete, {}}); + if (!questFail.empty()) + onQuestAction({QuestActionType::Fail, questFail, {}}); if (!objectiveComplete.empty()) { auto [qId, oId] = splitDot(objectiveComplete); - questJournal->setObjectiveCompleted(qId, oId); + onQuestAction({QuestActionType::CompleteObjective, qId, oId}); } if (!objectiveVisible.empty()) { auto [qId, oId] = splitDot(objectiveVisible); - questJournal->setObjectiveVisible(qId, oId); + onQuestAction({QuestActionType::ShowObjective, qId, oId}); } } diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index 8a599e1..1164e48 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -2,17 +2,29 @@ #include "dialogue/DialogueDatabase.h" #include "dialogue/TranslationDatabase.h" -#include "quest/QuestJournal.h" #include "external/nlohmann/json.hpp" #include "ISaveable.h" #include #include -#include #include #include namespace FRG::Dialogue { +enum class QuestActionType { + Unlock, + Complete, + Fail, + CompleteObjective, + ShowObjective +}; + +struct QuestActionRequest { + QuestActionType type; + std::string questId; + std::string objectiveId; +}; + class DialogueRuntime : public FRG::ISaveable { public: void setDatabase(const DialogueDatabase* value); @@ -41,8 +53,11 @@ public: void setFlag(const std::string& name, int value); int getFlag(const std::string& name) const; - void setGlobalFlagStore(std::unordered_map* store); - void setQuestJournal(Quest::QuestJournal* journal); + void setGlobalFlagAccessors( + std::function getter, + std::function setter); + void setQuestActionHandler( + std::function handler); // Saves whether a dialogue is active, its id, and current node. // Cutscene state is not saved. @@ -62,13 +77,14 @@ private: std::function onCutsceneStartNeeded; std::function onDialogueLineStarted; std::function onChatBubbleReady; + std::function onQuestAction; const DialogueDatabase* database = nullptr; const TranslationDatabase* translations = nullptr; - Quest::QuestJournal* questJournal = nullptr; const DialogueDefinition* activeDialogue = nullptr; - std::unordered_map* flagStore = nullptr; + std::function getGlobalFlag; + std::function setGlobalFlag; std::unordered_set consumedChoices; std::string currentNodeId; diff --git a/src/dialogue/DialogueSystem.h b/src/dialogue/DialogueSystem.h index e17960a..ac3869f 100644 --- a/src/dialogue/DialogueSystem.h +++ b/src/dialogue/DialogueSystem.h @@ -6,7 +6,6 @@ #include "cutscene/CutsceneDatabase.h" #include "cutscene/CutsceneOverlay.h" #include "cutscene/CutsceneRuntime.h" -#include "quest/QuestJournal.h" #include "ISaveable.h" #include #include @@ -58,11 +57,15 @@ public: void setFlag(const std::string& name, int value) { dialogueRuntime.setFlag(name, value); } int getFlag(const std::string& name) const { return dialogueRuntime.getFlag(name); } - void setGlobalFlagStore(std::unordered_map* store) { - dialogueRuntime.setGlobalFlagStore(store); + void setGlobalFlagAccessors( + std::function getter, + std::function setter) { + dialogueRuntime.setGlobalFlagAccessors( + std::move(getter), std::move(setter)); } - void setQuestJournal(Quest::QuestJournal* journal) { - dialogueRuntime.setQuestJournal(journal); + void setQuestActionHandler( + std::function handler) { + dialogueRuntime.setQuestActionHandler(std::move(handler)); } private: diff --git a/src/game_server/IGameServer.h b/src/game_server/IGameServer.h index 19fa227..b32438a 100644 --- a/src/game_server/IGameServer.h +++ b/src/game_server/IGameServer.h @@ -94,8 +94,51 @@ struct AppendChatMessageCommand { }; struct SetTaxiCalledCommand { bool called; }; struct StartTaxiCommand { std::string locationId; }; +struct ResetTaxiCommand { std::string locationId; }; +struct SetLocationCameraCommand { + std::string locationId; + float azimuth; + float inclination; +}; +struct SetLocationTargetsCommand { + std::string locationId; + std::string interactiveObjectId; + std::string npcId; + std::string teleportZoneId; +}; +struct SwitchLocationNavigationCommand { + std::string locationId; + int index; +}; +struct SetNavigationAreaAvailableCommand { + std::string locationId; + std::string areaName; + bool available; +}; +struct AddNavigationObstacleCommand { + std::string locationId; + std::string name; + std::vector polygon; +}; struct AddInventoryItemCommand { std::string itemId; }; struct RemoveInventoryItemCommand { std::string itemId; }; +struct UnlockQuestCommand { std::string questId; }; +struct CompleteQuestCommand { std::string questId; }; +struct FailQuestCommand { std::string questId; }; +struct SetQuestObjectiveCompletedCommand { + std::string questId; + std::string objectiveId; + bool completed; +}; +struct SetQuestObjectiveVisibleCommand { + std::string questId; + std::string objectiveId; + bool visible; +}; +struct SetQuestActiveObjectiveCommand { + std::string questId; + int objectiveIndex; +}; struct SetGlobalIntCommand { std::string key; int value; }; struct SetGlobalFloatCommand { std::string key; float value; }; struct SetCharacterTransformCommand { @@ -188,8 +231,20 @@ using GameCommand = std::variant< AppendChatMessageCommand, SetTaxiCalledCommand, StartTaxiCommand, + ResetTaxiCommand, + SetLocationCameraCommand, + SetLocationTargetsCommand, + SwitchLocationNavigationCommand, + SetNavigationAreaAvailableCommand, + AddNavigationObstacleCommand, AddInventoryItemCommand, RemoveInventoryItemCommand, + UnlockQuestCommand, + CompleteQuestCommand, + FailQuestCommand, + SetQuestObjectiveCompletedCommand, + SetQuestObjectiveVisibleCommand, + SetQuestActiveObjectiveCommand, SetGlobalIntCommand, SetGlobalFloatCommand, SetCharacterTransformCommand, @@ -209,13 +264,24 @@ using GameCommand = std::variant< SetTriggerZoneEnabledCommand, ReloadQuestDefinitionsCommand>; +struct InventoryItemAddedEvent { std::string itemId; }; +struct InventoryItemRemovedEvent { std::string itemId; }; + +using GameServerEvent = std::variant< + InventoryItemAddedEvent, + InventoryItemRemovedEvent, + Quest::QuestEvent>; + class IGameServer { public: virtual ~IGameServer() = default; // Each returned object is immutable and remains valid after later commands. virtual std::shared_ptr snapshot() const = 0; + // Client-relative identity assigned by this server connection. + virtual const std::string& localPlayerId() const = 0; virtual void submit(const GameCommand& command) = 0; + virtual std::vector drainEvents() = 0; virtual void update(std::int64_t deltaMs) = 0; // Persistence stays server-side. The JSON shape is GameState's established diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp index 524a12f..9bfa453 100644 --- a/src/game_server/InternalGameServer.cpp +++ b/src/game_server/InternalGameServer.cpp @@ -1,12 +1,192 @@ #include "InternalGameServer.h" +#include "../Campaign.h" #include "../Location.h" #include +#include #include 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; +} + InternalGameServer::InternalGameServer() = default; +void InternalGameServer::ensureBattleZoneTestBot() +{ + if (state_.campaignId != kBattleZoneCampaignId) return; + if (!kEnableBattleZoneTestBot) { + state_.players.erase(kBattleZoneTestBotId); + return; + } + + auto& bot = state_.players[kBattleZoneTestBotId]; + if (!bot) bot = std::make_shared(); + bot->configureAsPlayer(); + bot->npcName = "Test Bot"; +} + +void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs) +{ + if (!kEnableBattleZoneTestBot || + state_.campaignId != kBattleZoneCampaignId || + state_.currentLocationName.empty() || + state_.players.find(kBattleZoneTestBotId) == state_.players.end()) { + return; + } + + battleZoneTestBotElapsedMs_ += deltaMs; + if (battleZoneTestBotElapsedMs_ < kBattleZoneTestBotIntervalMs) return; + battleZoneTestBotElapsedMs_ %= kBattleZoneTestBotIntervalMs; + + static std::mt19937 randomEngine(std::random_device{}()); + static std::uniform_real_distribution coordinate(-10.0f, 10.0f); + submit(SetCharacterDestinationCommand{ + state_.currentLocationName, + kBattleZoneTestBotId, + Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)), + std::string()}); +} + +void InternalGameServer::initializeCampaign( + const std::string& campaignId, const std::string& questsPath, + const std::string& zipFile) +{ + state_.campaignId = campaignId; + state_.currentLocationName.clear(); + state_.locations.clear(); + state_.players.clear(); + state_.questJournal.loadFromFile(questsPath, zipFile); + state_.globalFloats["player_hp"] = 200.f; + + auto player = std::make_shared(); + player->configureAsPlayer(); + state_.players.emplace(localPlayerId_, std::move(player)); + battleZoneTestBotElapsedMs_ = 0; + ensureBattleZoneTestBot(); + publishMutation(); +} + +void InternalGameServer::clearCampaign() +{ + runtimeLocations_.clear(); + state_.currentLocationName.clear(); + state_.players.clear(); + state_.locations.clear(); + battleZoneTestBotElapsedMs_ = 0; + publishMutation(); +} + +void InternalGameServer::resetCampaign( + const std::string& questsPath, const std::string& zipFile) +{ + pendingEvents_.clear(); + state_.currentLocationName.clear(); + state_.isDarklands = false; + state_.isNight = false; + state_.isDawn = false; + state_.playerHp = 200.f; + state_.playerMaxHp = 200.f; + state_.money = 5500; + state_.globalInts.clear(); + state_.globalFloats.clear(); + state_.globalFloats["player_hp"] = 200.f; + state_.tutorialStep = TutorialStep::AdvanceDialogue; + state_.tutorialPhoneChatScreenOpened = false; + state_.tutorialJournalScreenOpened = false; + state_.tutorialNeedOpenTaxiScreen = false; + state_.tutorialTaxiHintDone = false; + for (int i = 0; i < 3; ++i) { + state_.chatUnread[i] = true; + state_.chatPreviewMsg[i].clear(); + state_.chatHistory[i].clear(); + } + state_.inventory.clear(); + state_.questJournal.loadFromFile(questsPath, zipFile); + state_.locations.clear(); + battleZoneTestBotElapsedMs_ = 0; + ensureBattleZoneTestBot(); + publishMutation(); +} + +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(); + } + 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; @@ -85,7 +265,11 @@ std::shared_ptr InternalGameServer::snapshot() const void InternalGameServer::submit(const GameCommand& command) { - std::visit([this](const auto& value) { + auto enqueueQuestEvents = [this](Quest::QuestMutationResult result) { + for (Quest::QuestEvent& event : result.events) + pendingEvents_.emplace_back(std::move(event)); + }; + std::visit([this, &enqueueQuestEvents](const auto& value) { using T = std::decay_t; if constexpr (std::is_same_v) { state_.isDarklands = value.darklands; @@ -126,10 +310,83 @@ void InternalGameServer::submit(const GameCommand& command) auto location = state_.locations.find(value.locationId); if (location != state_.locations.end()) location->second.startTaxi(); + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end()) + location->second.resetTaxi(); + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end() && location->second.location) { + location->second.location->cameraAzimuth = value.azimuth; + location->second.location->cameraInclination = value.inclination; + } + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end() && location->second.location) { + location->second.location->targetInteractiveObjectId = + value.interactiveObjectId; + location->second.location->targetInteractNpcId = value.npcId; + location->second.location->targetTeleportZoneId = + value.teleportZoneId; + } + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end() && location->second.location && + value.index >= 0 && + value.index < static_cast( + location->second.location->navigationMaps.size())) { + LocationState& locationState = *location->second.location; + locationState.activeNavigationIndex = value.index; + for (const auto& [id, player] : state_.players) + if (player) player->forceReplan(locationState, id); + for (const auto& [id, npc] : location->second.npcs) + if (npc) npc->forceReplan(locationState, id); + } + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end() && location->second.location) { + LocationState& locationState = *location->second.location; + const int index = locationState.activeNavigationIndex; + if (index >= 0 && + index < static_cast(locationState.navigationMaps.size())) { + locationState.navigationMaps[index].setAreaAvailable( + value.areaName, value.available); + } + } + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end() && location->second.location) { + LocationState& locationState = *location->second.location; + const int index = locationState.activeNavigationIndex; + if (index >= 0 && + index < static_cast(locationState.navigationMaps.size())) { + PathFinder::ObstaclePolygon polygon; + polygon.name = value.name; + polygon.polygon = value.polygon; + locationState.navigationMaps[index].addObstaclePolygon(polygon); + } + } } else if constexpr (std::is_same_v) { - state_.inventory.addItem(value.itemId); + if (state_.inventory.addItem(value.itemId)) + pendingEvents_.emplace_back(InventoryItemAddedEvent{value.itemId}); } else if constexpr (std::is_same_v) { - state_.inventory.removeItem(value.itemId); + if (state_.inventory.removeItem(value.itemId)) + pendingEvents_.emplace_back(InventoryItemRemovedEvent{value.itemId}); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.unlockQuest(value.questId)); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.completeQuest(value.questId)); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.failQuest(value.questId)); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.setObjectiveCompleted( + value.questId, value.objectiveId, value.completed)); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.setObjectiveVisible( + value.questId, value.objectiveId, value.visible)); + } else if constexpr (std::is_same_v) { + enqueueQuestEvents(state_.questJournal.setActiveObjective( + value.questId, value.objectiveIndex)); } else if constexpr (std::is_same_v) { state_.globalInts[value.key] = value.value; } else if constexpr (std::is_same_v) { @@ -225,7 +482,14 @@ void InternalGameServer::submit(const GameCommand& command) state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile); } }, command); - markHostMutation(); + publishMutation(); +} + +std::vector InternalGameServer::drainEvents() +{ + std::vector result; + result.swap(pendingEvents_); + return result; } void InternalGameServer::update(std::int64_t deltaMs) @@ -234,11 +498,15 @@ void InternalGameServer::update(std::int64_t deltaMs) if (runtime == runtimeLocations_.end() || !runtime->second) return; 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 = 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(); } @@ -251,9 +519,12 @@ void InternalGameServer::save(nlohmann::json& out) const void InternalGameServer::load(const nlohmann::json& in) { + pendingEvents_.clear(); state_.load(in); + battleZoneTestBotElapsedMs_ = 0; + ensureBattleZoneTestBot(); applyStateToRuntime(); - markHostMutation(); + publishMutation(); } void InternalGameServer::bindRuntimeLocation(const std::string& id, std::shared_ptr location) @@ -265,7 +536,7 @@ void InternalGameServer::bindRuntimeLocation(const std::string& id, std::shared_ } else { runtimeLocations_[id]->captureAuxiliaryState(state_.locations[id]); } - markHostMutation(); + publishMutation(); } void InternalGameServer::synchronizeRuntimeAuxiliaryState() @@ -274,7 +545,7 @@ void InternalGameServer::synchronizeRuntimeAuxiliaryState() if (!location) continue; location->captureAuxiliaryState(state_.locations[id]); } - markHostMutation(); + publishMutation(); } void InternalGameServer::applyStateToRuntime() @@ -286,7 +557,7 @@ void InternalGameServer::applyStateToRuntime() } } -void InternalGameServer::markHostMutation() +void InternalGameServer::publishMutation() { ++revision_; cachedSnapshot_.reset(); diff --git a/src/game_server/InternalGameServer.h b/src/game_server/InternalGameServer.h index c7a18ad..2e55ac0 100644 --- a/src/game_server/InternalGameServer.h +++ b/src/game_server/InternalGameServer.h @@ -2,11 +2,15 @@ #include "IGameServer.h" +class AudioPlayerAsync; + namespace FRG { class Game; class Character; class Location; +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. @@ -15,7 +19,9 @@ public: InternalGameServer(); std::shared_ptr snapshot() const override; + const std::string& localPlayerId() const override { return localPlayerId_; } void submit(const GameCommand& command) override; + std::vector drainEvents() override; void update(std::int64_t deltaMs) override; void save(nlohmann::json& out) const override; void load(const nlohmann::json& in) override; @@ -23,25 +29,38 @@ public: private: friend class Game; - // Transitional host operations are intentionally available only to Game, - // which owns location loading and old-save migration. They are never part of - // IGameServer and therefore cannot leak mutable state to UI/network clients. - GameState& hostState() { return state_; } - const GameState& hostState() const { return state_; } - std::unordered_map>& runtimeLocations() { return runtimeLocations_; } - const std::unordered_map>& runtimeLocations() const { return runtimeLocations_; } + // Host-only lifecycle operations. They never expose authoritative state and + // are intentionally absent from IGameServer. + void initializeCampaign( + const std::string& campaignId, const std::string& questsPath, + 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 markHostMutation(); + void publishMutation(); + void ensureBattleZoneTestBot(); + void updateBattleZoneTestBot(std::int64_t deltaMs); GameSnapshot buildSnapshot() const; GameState state_; + std::string localPlayerId_ = "player"; + std::int64_t battleZoneTestBotElapsedMs_ = 0; std::unordered_map> runtimeLocations_; std::uint64_t revision_ = 1; mutable std::uint64_t cachedRevision_ = 0; mutable std::shared_ptr cachedSnapshot_; + std::vector pendingEvents_; }; } // namespace FRG diff --git a/src/items/Item.cpp b/src/items/Item.cpp index 18027fb..d3db3e4 100644 --- a/src/items/Item.cpp +++ b/src/items/Item.cpp @@ -8,19 +8,20 @@ namespace FRG { - void Inventory::addItem(const std::string& itemId) { + bool Inventory::addItem(const std::string& itemId) { itemIds.push_back(itemId); logger() << "Item added to inventory: " << itemId << std::endl; - if (onItemAdded) onItemAdded(itemId); + return true; } - void Inventory::removeItem(const std::string& itemId) { + 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; - if (onItemRemoved) onItemRemoved(itemId); itemIds.erase(it); + return true; } + return false; } bool Inventory::hasItem(const std::string& itemId) const { diff --git a/src/items/Item.h b/src/items/Item.h index 0c7f489..65f57e6 100644 --- a/src/items/Item.h +++ b/src/items/Item.h @@ -2,7 +2,6 @@ #include #include #include -#include #include "ISaveable.h" namespace FRG { @@ -22,8 +21,8 @@ namespace FRG { class Inventory : public ISaveable { public: - void addItem(const std::string& itemId); - void removeItem(const std::string& itemId); + bool addItem(const std::string& itemId); + bool removeItem(const std::string& itemId); // Resolves current items from ItemRegistry by id, so returned data // always reflects the currently loaded (localized) definitions. std::vector getItems() const; @@ -34,10 +33,6 @@ namespace FRG { void save(nlohmann::json& out) const override; void load(const nlohmann::json& in) override; - // Called whenever an item is added/removed; receives the item id. - std::function onItemAdded; - std::function onItemRemoved; - private: std::vector itemIds; }; diff --git a/src/quest/QuestJournal.cpp b/src/quest/QuestJournal.cpp index 8249aae..bd4ff7d 100644 --- a/src/quest/QuestJournal.cpp +++ b/src/quest/QuestJournal.cpp @@ -106,38 +106,48 @@ bool QuestJournal::setStatus(const std::string& questId, QuestStatus status) { return true; } -bool QuestJournal::unlockQuest(const std::string& questId) { +QuestMutationResult QuestJournal::unlockQuest(const std::string& questId) { + QuestMutationResult result; QuestState* quest = findQuest(questId); - if (!quest) return false; + if (!quest) return result; + result.success = true; if (quest->status == QuestStatus::Hidden) { quest->status = QuestStatus::Available; - if (onQuestUnlocked) onQuestUnlocked(questId); + result.events.push_back({QuestEventType::Unlocked, questId, {}}); } - return true; + return result; } -bool QuestJournal::completeQuest(const std::string& questId) { - bool changed = setStatus(questId, QuestStatus::Completed); - if (changed && onQuestCompleted) onQuestCompleted(questId); - return changed; +QuestMutationResult QuestJournal::completeQuest(const std::string& questId) { + QuestMutationResult result; + result.success = setStatus(questId, QuestStatus::Completed); + if (result.success) + result.events.push_back({QuestEventType::Completed, questId, {}}); + return result; } -bool QuestJournal::failQuest(const std::string& questId) { - bool changed = setStatus(questId, QuestStatus::Failed); - if (changed && onQuestFailed) onQuestFailed(questId); - return changed; +QuestMutationResult QuestJournal::failQuest(const std::string& questId) { + QuestMutationResult result; + result.success = setStatus(questId, QuestStatus::Failed); + if (result.success) + result.events.push_back({QuestEventType::Failed, questId, {}}); + return result; } -bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed) { +QuestMutationResult QuestJournal::setObjectiveCompleted( + const std::string& questId, const std::string& objectiveId, bool completed) { + QuestMutationResult result; QuestState* quest = findQuest(questId); - if (!quest) return false; + if (!quest) return result; for (auto& objective : quest->definition.objectives) { if (objective.id == objectiveId) { objective.completed = completed; + result.success = true; if (completed) { - if (onObjectiveCompleted) onObjectiveCompleted(questId, objectiveId); + result.events.push_back( + {QuestEventType::ObjectiveCompleted, questId, objectiveId}); } if (completed && quest->definition.autoComplete && quest->status == QuestStatus::Available) { @@ -147,41 +157,48 @@ bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std:: [](const QuestObjective& o) { return o.completed; }); if (allDone) { quest->status = QuestStatus::Completed; - if (onQuestCompleted) onQuestCompleted(questId); + result.events.push_back( + {QuestEventType::Completed, questId, {}}); } } - return true; + return result; } } - return false; + return result; } -bool QuestJournal::setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible) { +QuestMutationResult QuestJournal::setObjectiveVisible( + const std::string& questId, const std::string& objectiveId, bool visible) { + QuestMutationResult result; QuestState* quest = findQuest(questId); - if (!quest) return false; + if (!quest) return result; for (auto& objective : quest->definition.objectives) { if (objective.id == objectiveId) { objective.visible = visible; - return true; + result.success = true; + return result; } } - return false; + return result; } -bool QuestJournal::setActiveObjective(const std::string& questId, int objectiveIndex) { +QuestMutationResult QuestJournal::setActiveObjective( + const std::string& questId, int objectiveIndex) { + QuestMutationResult result; QuestState* quest = findQuest(questId); - if (!quest) return false; + if (!quest) return result; if (objectiveIndex < 0 || objectiveIndex >= static_cast(quest->definition.objectives.size())) { - return false; + return result; } quest->activeObjectiveIndex = objectiveIndex; - return true; + result.success = true; + return result; } QuestState* QuestJournal::findQuest(const std::string& questId) { diff --git a/src/quest/QuestJournal.h b/src/quest/QuestJournal.h index d086325..45c06c6 100644 --- a/src/quest/QuestJournal.h +++ b/src/quest/QuestJournal.h @@ -6,10 +6,27 @@ #include #include #include -#include namespace FRG::Quest { +enum class QuestEventType { + Unlocked, + Completed, + Failed, + ObjectiveCompleted +}; + +struct QuestEvent { + QuestEventType type; + std::string questId; + std::string objectiveId; +}; + +struct QuestMutationResult { + bool success = false; + std::vector events; +}; + class QuestJournal : public FRG::ISaveable { public: bool loadFromFile(const std::string& path, const std::string& zipFile = ""); @@ -22,19 +39,13 @@ public: void save(nlohmann::json& out) const override; void load(const nlohmann::json& in) override; - // Event callbacks — set by the owner (MenuManager) to receive notifications. - std::function onQuestUnlocked; - std::function onQuestCompleted; - std::function onQuestFailed; - std::function onObjectiveCompleted; + QuestMutationResult unlockQuest(const std::string& questId); + QuestMutationResult completeQuest(const std::string& questId); + QuestMutationResult failQuest(const std::string& questId); - bool unlockQuest(const std::string& questId); - bool completeQuest(const std::string& questId); - bool failQuest(const std::string& questId); - - bool setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true); - bool setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible = true); - bool setActiveObjective(const std::string& questId, int objectiveIndex); + QuestMutationResult setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true); + QuestMutationResult setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible = true); + QuestMutationResult setActiveObjective(const std::string& questId, int objectiveIndex); QuestState* findQuest(const std::string& questId); const QuestState* findQuest(const std::string& questId) const; diff --git a/src/render/PathVisualization.cpp b/src/render/PathVisualization.cpp index bfb9d6d..084991a 100644 --- a/src/render/PathVisualization.cpp +++ b/src/render/PathVisualization.cpp @@ -100,13 +100,15 @@ namespace FRG { break; } + auto state = player.stateSnapshot(); + if (!state) return; std::vector route; - route.reserve(player.characterState.pathWaypoints.size() + 1); - route.push_back(Eigen::Vector3f(player.characterState.position.x(), 0.0f, player.characterState.position.z())); + route.reserve(state->pathWaypoints.size() + 1); + route.push_back(Eigen::Vector3f(state->position.x(), 0.0f, state->position.z())); const size_t firstWaypoint = (std::min)( - player.characterState.currentWaypointIndex, player.characterState.pathWaypoints.size()); - for (size_t i = firstWaypoint; i < player.characterState.pathWaypoints.size(); ++i) { - const Eigen::Vector3f& waypoint = player.characterState.pathWaypoints[i]; + state->currentWaypointIndex, state->pathWaypoints.size()); + for (size_t i = firstWaypoint; i < state->pathWaypoints.size(); ++i) { + const Eigen::Vector3f& waypoint = state->pathWaypoints[i]; route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z())); } @@ -127,7 +129,7 @@ namespace FRG { constexpr float kPlayerClearanceMs = 20.0f; constexpr float kPlayerMaximumSpeedMultiplier = 1.5f; const float playerSpeed = (std::max)( - player.characterState.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f); + state->walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f); const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f; const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f); const float waveSpeed = totalLength / (revealDurationMs / 1000.0f);