diff --git a/CHARACTER_INTERPOLATION.md b/CHARACTER_INTERPOLATION.md new file mode 100644 index 0000000..cd4713e --- /dev/null +++ b/CHARACTER_INTERPOLATION.md @@ -0,0 +1,67 @@ +# Character Presentation Interpolation + +The authoritative game simulation still advances in exact 100 ms ticks. Character +interpolation is a rendering-only feature and does not modify `CharacterState`, +network commands, tick IDs, state hashes, or deterministic lockstep behavior. + +## How it works + +Each runtime `Character` keeps two presentation samples: + +- `previous`: the visual values received at the preceding completed simulation tick; +- `current`: the visual values received at the latest completed simulation tick. + +Rendering calculates an alpha from the real elapsed time since the latest completed +tick, clamped to the 100 ms simulation interval. It renders the delayed +`previous -> current` transition. This introduces at most one simulation tick of +presentation latency, but it avoids predicting a state that has not arrived from +the authoritative simulation. + +The following values are interpolated: + +- Position uses linear interpolation. +- Facing angle uses the shortest angular path, including wraparound at ±π. +- Animation frame remains fractional and is interpolated when both samples use + the same animation state. Looping clips treat the last frame to first frame + transition as a cyclic interval instead of interpolating backward through the + entire clip. + +The interpolated values are used by mesh, weapon, shadow, night-lighting, name, +and health-bar rendering. The local camera and shadow-map center follow the same +interpolated player position; otherwise the camera would jump to the newest +authoritative position while the player model was still in its presentation +transition. Gameplay queries, commands, callbacks, collision, and presentation +events continue to use the authoritative snapshot. + +## Other presentation animations + +Teleport-zone star particles are advanced by `Location::updatePresentation()` +once per render frame using the real frame delta. They are no longer advanced +only from the 100 ms simulation-tick callback, so their visual motion remains +continuous without affecting teleport activation or other authoritative state. + +Dialogue typewriter text and cutscene presentation timers use the same +per-render-frame path. This keeps character reveals, subtitle timing, fades, and +cutscene image poses smooth instead of stepping in 100 ms increments. + +## Animation skinning + +GPU and CPU bone interpolation receive the fractional render frame instead of an +integer-truncated frame. Bone positions are linearly interpolated and bone +rotations use spherical interpolation as before. Frames at the end of a clip are +clamped to the final keyframe. + +## Discontinuities + +The presentation buffer is reset instead of blended when a character is first +observed, changes animation state or enabled state, or has not received a tick +for a sufficiently long gap. This prevents stale positions, animation clips, or +disabled characters from producing visible interpolation artifacts. + +## Local and networked games + +Local games continue accumulating wall-clock remainder time and executing exact +100 ms simulation updates. Networked games continue consuming only contiguous +server tick messages. Both paths publish completed snapshots to the same runtime +presentation objects; interpolation only changes how those snapshots are drawn +between updates. diff --git a/src/BoneAnimatedModelNew.cpp b/src/BoneAnimatedModelNew.cpp index 463908f..c1f38b1 100644 --- a/src/BoneAnimatedModelNew.cpp +++ b/src/BoneAnimatedModelNew.cpp @@ -722,9 +722,10 @@ namespace FRG return -1; } - void BoneSystemNew::Interpolate(int frame) + void BoneSystemNew::Interpolate(float frame) { int startingKeyFrame = -1; + int nextKeyFrame = -1; for (int i = 0; i < static_cast(animations[0].keyFrames.size()) - 1; i++) { int oldFrame = animations[0].keyFrames[i].frame; @@ -732,22 +733,29 @@ namespace FRG if (frame >= oldFrame && frame < nextFrame) { startingKeyFrame = i; + nextKeyFrame = i + 1; break; } } if (startingKeyFrame == -1) { - logger() << "Exception here: frame number is out of range of keyframes. Frame: " << frame << std::endl; - throw std::runtime_error("Exception here"); + if (animations[0].keyFrames.size() < 2) + return; + startingKeyFrame = static_cast(animations[0].keyFrames.size()) - 1; + nextKeyFrame = 0; } - int modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame; - int diffFrames = animations[0].keyFrames[startingKeyFrame + 1].frame - animations[0].keyFrames[startingKeyFrame].frame; - float t = (modifiedFrameNumber + 0.f) / diffFrames; + float modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame; + int diffFrames = nextKeyFrame == 0 + ? animations[0].keyFrames.back().frame - + animations[0].keyFrames.front().frame + 1 + : animations[0].keyFrames[nextKeyFrame].frame - + animations[0].keyFrames[startingKeyFrame].frame; + float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f; std::vector& oneFrameBones = animations[0].keyFrames[startingKeyFrame].bones; - std::vector& nextFrameBones = animations[0].keyFrames[startingKeyFrame + 1].bones; + std::vector& nextFrameBones = animations[0].keyFrames[nextKeyFrame].bones; std::vector skinningMatrixForEachBone(currentBones.size()); @@ -906,9 +914,10 @@ namespace FRG } } - void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector& startBones, const std::vector& keyFrames, int frame) + void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector& startBones, const std::vector& keyFrames, float frame) { int startingKeyFrame = -1; + int nextKeyFrame = -1; for (size_t i = 0; i < keyFrames.size() - 1; i++) { int oldFrame = keyFrames[i].frame; @@ -916,6 +925,7 @@ namespace FRG if (frame >= oldFrame && frame < nextFrame) { startingKeyFrame = static_cast(i); + nextKeyFrame = static_cast(i + 1); break; } } @@ -924,16 +934,23 @@ namespace FRG if (startingKeyFrame == -1) { - for (auto& m : skinningMatrices) m = Matrix4f::Identity(); - return; + if (keyFrames.size() < 2) + { + for (auto& m : skinningMatrices) m = Matrix4f::Identity(); + return; + } + startingKeyFrame = static_cast(keyFrames.size()) - 1; + nextKeyFrame = 0; } - int modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame; - int diffFrames = keyFrames[startingKeyFrame + 1].frame - keyFrames[startingKeyFrame].frame; - float t = (modifiedFrameNumber + 0.f) / diffFrames; + float modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame; + int diffFrames = nextKeyFrame == 0 + ? keyFrames.back().frame - keyFrames.front().frame + 1 + : keyFrames[nextKeyFrame].frame - keyFrames[startingKeyFrame].frame; + float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f; const std::vector& oneFrameBones = keyFrames[startingKeyFrame].bones; - const std::vector& nextFrameBones = keyFrames[startingKeyFrame + 1].bones; + const std::vector& nextFrameBones = keyFrames[nextKeyFrame].bones; for (size_t i = 0; i < startBones.size(); i++) { diff --git a/src/BoneAnimatedModelNew.h b/src/BoneAnimatedModelNew.h index c4b47f9..b4d323b 100644 --- a/src/BoneAnimatedModelNew.h +++ b/src/BoneAnimatedModelNew.h @@ -67,7 +67,7 @@ namespace FRG void LoadFromFile(const std::string& fileName, const std::string& ZIPFileName = ""); void LoadFromBinaryFile(const std::string& fileName, const std::string& ZIPFileName = ""); - void Interpolate(int frame); + void Interpolate(float frame); int findBoneIndex(const std::string& name) const; }; @@ -94,14 +94,14 @@ namespace FRG void prepareGpuSkinningVBOs(BoneSystemNew& model); void RenderVBO(Renderer& renderer, const std::vector& meshNamesOrdered); - void ComputeSkinningMatrices(const std::vector& startBones, const std::vector& keyFrames, int frame); + void ComputeSkinningMatrices(const std::vector& startBones, const std::vector& keyFrames, float frame); }; struct BoneAnimationDataNew { BoneSystemNew model; float currentFrame = 0.f; - int lastFrame = -1; + float lastFrame = -1.0f; int totalFrames = 1; GpuSkinningShaderDataNew gpuSkinningShaderData; diff --git a/src/Character.cpp b/src/Character.cpp index 233c23c..3df2861 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -52,6 +52,11 @@ Eigen::Vector3f Character::getCurrentNavigationTarget() const { return state ? state->getCurrentNavigationTarget() : Eigen::Vector3f::Zero(); } +Eigen::Vector3f Character::renderPosition() const { + auto state = stateSnapshot(); + return state ? interpolatedState(*state).position : Eigen::Vector3f::Zero(); +} + float Character::getHp() const { auto state = stateSnapshot(); return state ? state->getHp() : 0.f; @@ -163,6 +168,62 @@ AnimationState Character::resolveActiveState(const CharacterState& state) const return AnimationState::STAND; } +CharacterState Character::interpolatedState(const CharacterState& state) const { + if (!hasPresentationSample_ || !currentPresentationSample_.enabled || !state.enabled) { + return state; + } + + constexpr float kSimulationTickMs = 100.0f; + const auto now = std::chrono::steady_clock::now(); + const float elapsedMs = static_cast( + std::chrono::duration_cast( + now - lastPresentationUpdate_).count()); + const float alpha = (std::max)(0.0f, (std::min)(1.0f, elapsedMs / kSimulationTickMs)); + + CharacterState result = state; + result.position = previousPresentationSample_.position + + alpha * (currentPresentationSample_.position - previousPresentationSample_.position); + + constexpr float kPi = 3.14159265358979323846f; + constexpr float kTwoPi = 2.0f * kPi; + float angleDelta = currentPresentationSample_.facingAngle - + previousPresentationSample_.facingAngle; + while (angleDelta > kPi) angleDelta -= kTwoPi; + while (angleDelta < -kPi) angleDelta += kTwoPi; + result.facingAngle = previousPresentationSample_.facingAngle + alpha * angleDelta; + if (previousPresentationSample_.currentState == state.currentState && + currentPresentationSample_.currentState == state.currentState) { + float previousFrame = previousPresentationSample_.animationFrame; + float currentFrame = currentPresentationSample_.animationFrame; + const auto animationIndex = static_cast(state.currentState); + const int frameCount = animationIndex < state.animationFrameCounts.size() + ? state.animationFrameCounts[animationIndex] : 1; + if (frameCount > 1 && currentFrame < previousFrame) + currentFrame += static_cast(frameCount); + result.animationFrame = previousFrame + alpha * (currentFrame - previousFrame); + } + return result; +} + +void Character::updateAnimationForRender(const CharacterState& state) { + auto it = animations.find(state.currentState); + if (it == animations.end()) return; + + auto& anim = it->second; + anim.currentFrame = (std::min)( + state.animationFrame, static_cast((std::max)(0, anim.totalFrames))); + if (std::abs(anim.currentFrame - anim.lastFrame) <= 0.0001f) return; + + if (!anim.model.animations.empty() && !anim.model.animations[0].keyFrames.empty()) { + anim.gpuSkinningShaderData.ComputeSkinningMatrices( + anim.model.startBones, anim.model.animations[0].keyFrames, + anim.currentFrame); + if (!state.useGpuSkinning) + anim.model.Interpolate(anim.currentFrame); + } + anim.lastFrame = anim.currentFrame; +} + void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) { if (hitSparkEmitter.isConfigured()) { hitSparkEmitter.update(static_cast(deltaMs)); @@ -172,6 +233,26 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) { if (!stateSnapshot) return; const CharacterState& state = *stateSnapshot; + const auto now = std::chrono::steady_clock::now(); + PresentationSample sample; + sample.position = state.position; + sample.facingAngle = state.facingAngle; + sample.animationFrame = state.animationFrame; + sample.currentState = state.currentState; + sample.enabled = state.enabled; + const bool largeGap = hasPresentationSample_ && + std::chrono::duration_cast( + now - lastPresentationUpdate_).count() > 250; + if (!hasPresentationSample_ || largeGap || + currentPresentationSample_.enabled != sample.enabled) { + previousPresentationSample_ = sample; + } else { + previousPresentationSample_ = currentPresentationSample_; + } + currentPresentationSample_ = sample; + lastPresentationUpdate_ = now; + hasPresentationSample_ = true; + if (events) { if (events->deathAnimationComplete && onDeathAnimComplete) onDeathAnimComplete(); @@ -183,22 +264,7 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) { } if (!state.enabled) return; - auto it = animations.find(state.currentState); - if (it == animations.end()) return; - - auto& anim = it->second; - anim.currentFrame = (std::min)( - 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 (!state.useGpuSkinning) { - anim.model.Interpolate(static_cast(anim.currentFrame)); - } - anim.lastFrame = static_cast(anim.currentFrame); - } + updateAnimationForRender(state); } void Character::draw(Renderer& renderer) { @@ -211,8 +277,9 @@ void Character::draw(Renderer& renderer) { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (!state.enabled) return; + updateAnimationForRender(state); if (state.useGpuSkinning) { drawGpuSkinning(renderer, state); @@ -270,7 +337,7 @@ void Character::drawGpuSkinning(Renderer& renderer, const CharacterState& state) if (anim.model.animations.empty() || anim.model.animations[0].keyFrames.empty()) return; anim.gpuSkinningShaderData.ComputeSkinningMatrices( anim.model.startBones, anim.model.animations[0].keyFrames, - static_cast(anim.currentFrame)); + anim.currentFrame); if (anim.gpuSkinningShaderData.skinningMatrices.empty()) return; } @@ -328,7 +395,7 @@ bool Character::prepareGpuSkinning(const CharacterState& state) { if (anim.model.animations.empty() || anim.model.animations[0].keyFrames.empty()) return false; anim.gpuSkinningShaderData.ComputeSkinningMatrices( anim.model.startBones, anim.model.animations[0].keyFrames, - static_cast(anim.currentFrame)); + anim.currentFrame); if (anim.gpuSkinningShaderData.skinningMatrices.empty()) return false; } return true; @@ -370,8 +437,9 @@ void Character::drawAttachedWeapon(Renderer& renderer, const CharacterState& sta void Character::drawShadowDepth(Renderer& renderer) { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (!state.enabled) return; + updateAnimationForRender(state); if (state.useGpuSkinning) { drawShadowDepthGpuSkinning(renderer, state); } else { @@ -453,8 +521,9 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterSt void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (!state.enabled) return; + updateAnimationForRender(state); if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } @@ -603,8 +672,9 @@ void Character::drawNight(Renderer& renderer, { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (!state.enabled) return; + updateAnimationForRender(state); if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } @@ -766,8 +836,9 @@ void Character::drawNightWithShadow(Renderer& renderer, { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (!state.enabled) return; + updateAnimationForRender(state); if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); } @@ -1014,7 +1085,7 @@ void Character::drawName(TextRenderer& textRenderer, { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (state.isPlayer) return; if (state.npcName.empty()) return; @@ -1037,10 +1108,11 @@ void Character::drawPlayerName( { auto snapshot = stateSnapshot(); if (!snapshot) return; + CharacterState state = interpolatedState(*snapshot); static const std::array color = {0.f, 1.f, 0.f, 1.f}; drawCharacterNameLabel( textRenderer, cameraViewMatrix, projectionMatrix, - *snapshot, displayName, color); + state, displayName, color); } void Character::drawHealthBar(Renderer& renderer, @@ -1049,7 +1121,7 @@ void Character::drawHealthBar(Renderer& renderer, { auto stateSnapshot = this->stateSnapshot(); if (!stateSnapshot) return; - const CharacterState& state = *stateSnapshot; + CharacterState state = interpolatedState(*stateSnapshot); if (state.isPlayer || !state.canAttack) return; if (!state.enabled) return; if (state.hp <= 0.f) return; diff --git a/src/Character.h b/src/Character.h index 2e76055..e71371f 100644 --- a/src/Character.h +++ b/src/Character.h @@ -12,6 +12,7 @@ #include #include #include +#include #include "dialogue/TranslationDatabase.h" #include "AudioPlayerAsync.h" @@ -94,6 +95,7 @@ public: bool isMoving() const; Eigen::Vector3f getRequestedWalkTarget() const; Eigen::Vector3f getCurrentNavigationTarget() const; + Eigen::Vector3f renderPosition() const; float getHp() const; // ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ---- @@ -124,7 +126,22 @@ private: VertexRenderStruct healthBarMesh; std::unordered_map> meshTextures; + struct PresentationSample { + Eigen::Vector3f position = Eigen::Vector3f::Zero(); + float facingAngle = 0.0f; + float animationFrame = 0.0f; + AnimationState currentState = AnimationState::STAND; + bool enabled = true; + }; + + PresentationSample previousPresentationSample_; + PresentationSample currentPresentationSample_; + std::chrono::steady_clock::time_point lastPresentationUpdate_; + bool hasPresentationSample_ = false; + AnimationState resolveActiveState(const CharacterState& state) const; + CharacterState interpolatedState(const CharacterState& state) const; + void updateAnimationForRender(const CharacterState& state); bool prepareGpuSkinning(const CharacterState& state); void drawAttachedWeapon(Renderer& renderer, const CharacterState& state); diff --git a/src/Game.cpp b/src/Game.cpp index d94a361..2bd5e79 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -1194,6 +1194,8 @@ namespace FRG simulationAccumulatorMs -= kSimulationTickMs; } } + if (Location* location = currentLocation()) + location->updatePresentation(delta); #if defined(NETWORK) if (remoteCampaignActive && websocketGameClient && websocketGameClient->connectionFailed()) { diff --git a/src/Location.cpp b/src/Location.cpp index 0d13276..a6b2cb9 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -1011,7 +1011,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = - playerState ? playerState->position : Eigen::Vector3f::Zero(); + player ? player->renderPosition() : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1088,7 +1088,7 @@ namespace FRG auto playerState = player ? player->stateSnapshot() : nullptr; const Eigen::Vector3f sceneCenter = - playerState ? playerState->position : Eigen::Vector3f::Zero(); + player ? player->renderPosition() : Eigen::Vector3f::Zero(); shadowMap->updateLightSpaceMatrix(sceneCenter); shadowMap->bind(); @@ -1206,7 +1206,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); auto playerState = player ? player->stateSnapshot() : nullptr; const Eigen::Vector3f camTarget = - playerState ? playerState->position : Eigen::Vector3f::Zero(); + player ? player->renderPosition() : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1345,7 +1345,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = - playerState ? playerState->position : Eigen::Vector3f::Zero(); + player ? player->renderPosition() : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1591,7 +1591,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = - playerState ? playerState->position : Eigen::Vector3f::Zero(); + player ? player->renderPosition() : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1860,7 +1860,6 @@ namespace FRG if (player) { player->update(delta, eventFor(gameServer_.localPlayerId())); - dialogueSystem.update(static_cast(delta)); auto playerState = player->stateSnapshot(); if (playerState) updateTriggerZones(playerState->position); } @@ -1896,8 +1895,6 @@ namespace FRG } } - for (auto& tz : teleportZones) tz.update(static_cast(delta)); - auto playerState = player ? player->stateSnapshot() : nullptr; if (auto* tz = getTargetTeleportZone(*locationState); tz && playerState) { float dist = (playerState->position - tz->position).norm(); @@ -2179,6 +2176,13 @@ namespace FRG } } + void Location::updatePresentation(int64_t delta) + { + dialogueSystem.update(static_cast(delta)); + for (auto& tz : teleportZones) + tz.update(static_cast(delta)); + } + bool Location::requestDialogueStart(const std::string& dialogueId) { return dialogueSystem.startDialogue(dialogueId); diff --git a/src/Location.h b/src/Location.h index 2dbfe7f..7905e62 100644 --- a/src/Location.h +++ b/src/Location.h @@ -210,6 +210,7 @@ namespace FRG bool setNavigationAreaAvailable(const std::string& areaName, bool available); void update(int64_t deltaMs, const LocationSimulationEvents& events); + void updatePresentation(int64_t deltaMs); // Purely visual feedback for click-triggered player routes. Disabling it // also cancels an animation that is currently playing. void setPathVisualizationEnabled(bool enabled);