From e2aff51188b056bdacf76e4f95c5f9ebcf293178 Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sun, 5 Jan 2025 23:15:45 +0300 Subject: [PATCH] Fix sliding NPCs by adding a replay cache for animations Having a single `LatestAction` wasn't enough to bring Actors into a correct animation state. Now non-cell-owners (aka party members) will replay recent actions when an Actor is spawned in their world --- Code/client/Games/Animation.cpp | 2 +- .../Services/Generic/CharacterService.cpp | 26 ++- .../Messages/CharacterSpawnRequest.cpp | 18 +- .../encoding/Messages/CharacterSpawnRequest.h | 1 + Code/server/Components/AnimationComponent.h | 1 + Code/server/Game/AnimationEventLists.cpp | 191 ++++++++++++++++++ Code/server/Game/AnimationEventLists.h | 16 ++ Code/server/Services/CharacterService.cpp | 87 +++++++- 8 files changed, 325 insertions(+), 17 deletions(-) create mode 100644 Code/server/Game/AnimationEventLists.cpp create mode 100644 Code/server/Game/AnimationEventLists.h diff --git a/Code/client/Games/Animation.cpp b/Code/client/Games/Animation.cpp index 80dcc1ad4..476e69118 100644 --- a/Code/client/Games/Animation.cpp +++ b/Code/client/Games/Animation.cpp @@ -112,7 +112,7 @@ bool ActorMediator::ForceAction(TESActionData* apAction) noexcept uint8_t result = 0; auto pActor = static_cast(apAction->actor); - if (!pActor || pActor->animationGraphHolder.IsReady()) + if (pActor && pActor->animationGraphHolder.IsReady()) { result = TiltedPhoques::ThisCall(PerformComplexAction, this, apAction); diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 94ca4302b..85bba6145 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -335,8 +335,11 @@ void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessag pActor->GetExtension()->SetRemote(true); - InterpolationSystem::Setup(m_world, cEntity); - AnimationSystem::Setup(m_world, cEntity); + // TODO: `AnimationSystem::Setup` erases my actions replay cache, gotta figure out why + // these two lines were added in the first place + + //InterpolationSystem::Setup(m_world, cEntity); + //AnimationSystem::Setup(m_world, cEntity); pActor->SetActorValues(acMessage.AllActorValues); pActor->SetActorInventory(acMessage.CurrentInventory); @@ -400,11 +403,12 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) auto waitingView = m_world.view(); const auto waitingItor = std::find_if(std::begin(waitingView), std::end(waitingView), [waitingView, cActorId](auto entity) { return waitingView.get(entity).Id == cActorId; }); - if (waitingItor != std::end(waitingView)) - { - spdlog::info("Character with form id {:X} already has a spawn request in progress.", cActorId); - return; - } + // TODO: Sometimes actors have "a spawn request in progress" when they shouldn't, debug this.. + //if (waitingItor != std::end(waitingView)) + //{ + // spdlog::info("Character with form id {:X} already has a spawn request in progress.", cActorId); + // return; + //} auto* const pForm = TESForm::GetById(cActorId); pActor = Cast(pForm); @@ -472,7 +476,13 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) m_world.emplace_or_replace(*entity, acMessage); auto& remoteAnimationComponent = m_world.get(*entity); - remoteAnimationComponent.TimePoints.push_back(acMessage.LatestAction); + + for (const ActionEvent& action : acMessage.ActionsReplayCache) + { + if (action.EventName.empty()) // TODO: skip empties for now. figure out why an empty event is present after deserialization + continue; + remoteAnimationComponent.TimePoints.push_back(action); + } } void CharacterService::OnRemoteSpawnDataReceived(const NotifySpawnData& acMessage) noexcept diff --git a/Code/encoding/Messages/CharacterSpawnRequest.cpp b/Code/encoding/Messages/CharacterSpawnRequest.cpp index 7e23c71d3..e972ac53c 100644 --- a/Code/encoding/Messages/CharacterSpawnRequest.cpp +++ b/Code/encoding/Messages/CharacterSpawnRequest.cpp @@ -12,7 +12,17 @@ void CharacterSpawnRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) Serialization::WriteString(aWriter, AppearanceBuffer); InventoryContent.Serialize(aWriter); FactionsContent.Serialize(aWriter); + + // Actions LatestAction.GenerateDifferential(ActionEvent{}, aWriter); + aWriter.WriteBits(ActionsReplayCache.size() & 0xFF, 8); + ActionEvent lastSerialized{}; + for (int i = 0; i < ActionsReplayCache.size(); ++i) + { + ActionsReplayCache[i].GenerateDifferential(lastSerialized, aWriter); + lastSerialized = ActionsReplayCache[i]; + } + FaceTints.Serialize(aWriter); InitialActorValues.Serialize(aWriter); Serialization::WriteVarInt(aWriter, PlayerId); @@ -44,9 +54,15 @@ void CharacterSpawnRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReade FactionsContent = {}; FactionsContent.Deserialize(aReader); + // Actions LatestAction = ActionEvent{}; LatestAction.ApplyDifferential(aReader); - + uint64_t replayActionsCount = 0; + aReader.ReadBits(replayActionsCount, 8); + ActionsReplayCache.resize(replayActionsCount); + for (ActionEvent& replayAction : ActionsReplayCache) + replayAction.ApplyDifferential(aReader); + FaceTints.Deserialize(aReader); InitialActorValues.Deserialize(aReader); PlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; diff --git a/Code/encoding/Messages/CharacterSpawnRequest.h b/Code/encoding/Messages/CharacterSpawnRequest.h index 30f25f0fb..066fc318e 100644 --- a/Code/encoding/Messages/CharacterSpawnRequest.h +++ b/Code/encoding/Messages/CharacterSpawnRequest.h @@ -42,6 +42,7 @@ struct CharacterSpawnRequest final : ServerMessage Inventory InventoryContent{}; Factions FactionsContent{}; ActionEvent LatestAction{}; + Vector ActionsReplayCache{}; Tints FaceTints{}; ActorValues InitialActorValues{}; uint32_t PlayerId{}; diff --git a/Code/server/Components/AnimationComponent.h b/Code/server/Components/AnimationComponent.h index 510ae2db0..9f53eaa5b 100644 --- a/Code/server/Components/AnimationComponent.h +++ b/Code/server/Components/AnimationComponent.h @@ -9,6 +9,7 @@ struct AnimationComponent { Vector Actions; + Vector ActionsReplayCache; ActionEvent CurrentAction; ActionEvent LastSerializedAction; }; diff --git a/Code/server/Game/AnimationEventLists.cpp b/Code/server/Game/AnimationEventLists.cpp new file mode 100644 index 000000000..36d29b354 --- /dev/null +++ b/Code/server/Game/AnimationEventLists.cpp @@ -0,0 +1,191 @@ +#include + +/* +/* The lists here may not contain all relevant animation events, so extend as necessary +*/ + +const Set AnimationEventLists::g_actionsStart = { + {"moveStart"}, + {"bowAttackStart"}, + {"blockStart"}, + {"staggerStart"}, + {"staggerIdleStart"}, + {"shoutStart"}, + {"SwimStart"}, + {"SneakStart"}, + {"torchEquip"}, + {"blockHitStart"}, + {"bleedOutStart"}, + {"blockAnticipateStart"}, + {"HorseEnter"}, + {"HorseEnterInstant"}, + {"HorseEnterSwim"}, + {"MountedSwimStart"}, + {"ChairLookDownEnterInstant"}, + {"RagdollInstant"}, + // Idle animations + {"IdleAlchemyEnter"}, + {"IdleBarDrinkingStart"}, + {"IdleBedEnterInstant"}, + {"IdleBedEnterStart"}, + {"IdleBedLeftEnterInstant"}, + {"IdleBedLeftEnterStart"}, + {"IdleBedRightEnterInstant"}, + {"IdleBedRightEnterStart"}, + {"IdleBedRollFrontEnterInstant"}, + {"IdleBedRollFrontEnterStart"}, + {"IdleBedRollLeftEnterInstant"}, + {"IdleBedRollLeftEnterStart"}, + {"IdleBedRollRightEnterInstant"}, + {"IdleBedRollRightEnterStart"}, + {"IdleBeggar"}, + {"IdleBlacksmithForgeEnter"}, + {"IdleBlackSmithingEnterInstant"}, + {"IdleBlackSmithingEnterStart"}, + {"IdleBoundKneesStart"}, + {"IdleCarryBucketFillEnter"}, + {"IdleCarryBucketPourEnter"}, + {"IdleCartBenchEnter"}, + {"IdleCartBenchEnterInstant"}, + {"IdleChairEnterInstant"}, + {"IdleChairEnterStart"}, + {"IdleChairEnterToSit"}, + {"IdleChairFrontEnter"}, + {"IdleChairLeftEnter"}, + {"IdleChairRightEnter"}, + {"IdleCombatShieldStart"}, + {"IdleCombatStart"}, + {"IdleCookingSpitEnter"}, + {"IdleCounterStart"}, + {"IdleDialogueAngryStart"}, + {"IdleDialogueExpressiveStart"}, + {"IdleDialogueHappyStart"}, + {"idleDrinkingStandingStart"}, + {"IdleDrumStart"}, + {"idleEatingStandingStart"}, + {"IdleEnchantingEnter"}, + {"IdleFluteStart"}, + {"IdleFurnitureStart"}, + {"IdleGetAttention"}, + {"IdleHammerTableEnter"}, + {"IdleHammerTableEnterInstant"}, + {"IdleHammerWallEnter"}, + {"IdleHammerWallEnterInstant"}, + {"IdleHideLEnter"}, + {"IdleHideREnter"}, + {"IdleJarlChairEnter"}, + {"IdleJarlChairEnterInstant"}, + {"IdleLadderEnter"}, + {"IdleLadderEnterInstant"}, + {"IdleLayDownEnter"}, + {"IdleLayDownEnterInstant"}, + {"IdleLeanTable"}, + {"IdleLeanTableEnter"}, + {"IdleLeanTableEnterInstant"}, + {"IdleLeftChairEnterStart"}, + {"IdleLeverPushStart"}, + {"idleLooseSweepingStart"}, + {"IdleLuteStart"}, + {"IdleMillLoadStart"}, + {"IdlePickaxeEnter"}, + {"IdlePickaxeEnterInstant"}, + {"IdlePickaxeFloorEnter"}, + {"IdlePickaxeTableEnter"}, + {"IdleReadElderScrollStart"}, + {"IdleRightChairEnterStart"}, + {"IdleSearchingChest"}, + {"IdleSearchingTable"}, + {"IdleSharpeningWheelStart"}, + {"IdleSitCrossLeggedEnter"}, + {"IdleSitCrossLeggedEnterInstant"}, + {"IdleSitLedge"}, + {"IdleSitLedge_Enter"}, + {"IdleSitLedgeEnter"}, + {"IdleSitLedgeEnterInstant"}, + {"IdleStoolEnter"}, + {"IdleStoolEnterInstant"}, + {"IdleTableEnter"}, + {"IdleTableEnterInstant"}, + {"IdleTanningEnter"}, + {"IdleWallLeanStart"}, + {"IdleWarmHands"}, + {"IdleWebEnterInstant"}, + {"IdleWoodChopStart"}, + {"IdleWoodPickUpEnter"}, + {"IdleWoodPickUpEnterInstant"}, + {"IdleChairCHILDEnterInstant"}, + {"IdleChairCHILDFrontEnter"}, + {"IdleChairCHILDLeftEnter"}, + {"IdleChairCHILDRightEnter"}, +}; + +const Set AnimationEventLists::g_actionsExit = { + {"IdleForceDefaultState"}, // Belongs here too + {"BleedOutEarlyExit"}, + {"HorseExit"}, + {"IdleChairExitToStand"}, + {"IdleChairFrontExit"}, + {"idleChairLeftExit"}, + {"idleChairRightExit"}, + {"IdleBedExitToStand"}, + {"IdleCartPrisonerAExit"}, + {"IdleFurnitureExit"}, + {"IdleLaydown_Exit"}, + {"IdleLounge_Exit"}, + {"IdleRailLeanExit"}, + {"IdleSitLedge_Exit"}, + {"IdleStoolBackExit"}, + {"IdleTableBackExit"}, + {"IdleWebExit"}, + {"IdleChairExitStart"}, + {"IdleBedExitStart"}, + {"IdleBedLeftExitStart"}, + {"IdleBedRightExitStart"}, + {"IdleBedRollFrontExitStart"}, + {"IdleBedRollLeftExitStart"}, + {"IdleBedRollRightExitStart"}, + {"IdleChairCHILDFrontExit"}, + {"IdleChairCHILDLeftExit"}, + {"IdleChairCHILDRightExit"}, +}; + +// Skip these in the loop when searching for a valid animation chain start +const Set AnimationEventLists::g_actionsSkipIntermediate = { + {"attackStart"}, + {"turnStop"}, + {"moveStop"}, + {"WeapEquip"}, + {"SprintStart"}, + {"SprintStop"}, + {"CyclicFreeze"}, + {"CyclicCrossBlend"}, + {"IdleStop"}, + {"IdleStopInstant"}, + {"IdleHDLeft"}, + {"IdleHDLeftAngry"}, + {"IdleHDRight"}, + {"IdleHDRightAngry"}, + {"MotionDrivenIdle"}, + {"torchUnequip"}, +}; + +const Set AnimationEventLists::g_actionsIgnore = { + // Animation events with empty names don't carry anything useful besides + // various animvars updates and state changes to the running animations. + // Ignored because there is too many of them on each frame + {""}, + {"TurnLeft"}, + {"TurnRight"}, + {"NPC_TurnLeft180"}, + {"NPC_TurnLeft90"}, + {"NPC_TurnRight180"}, + {"NPC_TurnRight90"}, + {"NPC_TurnToWalkLeft180"}, + {"NPC_TurnToWalkLeft90"}, + {"NPC_TurnToWalkRight180"}, + {"NPC_TurnToWalkRight90"}, + // There's an elusive bug on the client where it would spam "Unequip" + // and "combatStanceStop" a lot after changing cells + {"Unequip"}, + {"combatStanceStop"}, +}; diff --git a/Code/server/Game/AnimationEventLists.h b/Code/server/Game/AnimationEventLists.h new file mode 100644 index 000000000..f3a379998 --- /dev/null +++ b/Code/server/Game/AnimationEventLists.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +using TiltedPhoques::Set, TiltedPhoques::String; + +namespace AnimationEventLists +{ +extern const Set g_actionsStart; + +extern const Set g_actionsExit; + +extern const Set g_actionsSkipIntermediate; + +extern const Set g_actionsIgnore; +} // namespace diff --git a/Code/server/Services/CharacterService.cpp b/Code/server/Services/CharacterService.cpp index 690dae088..278e3656e 100644 --- a/Code/server/Services/CharacterService.cpp +++ b/Code/server/Services/CharacterService.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -120,7 +121,7 @@ void CharacterService::Serialize(World& aRegistry, entt::entity aEntity, Charact } const auto& animationComponent = aRegistry.get(aEntity); - apSpawnRequest->LatestAction = animationComponent.CurrentAction; + apSpawnRequest->ActionsReplayCache = animationComponent.ActionsReplayCache; } void CharacterService::OnUpdate(const UpdateEvent&) const noexcept @@ -780,6 +781,77 @@ void CharacterService::ProcessFactionsChanges() const noexcept } } +static bool IsStartAction(const ActionEvent& action) noexcept +{ + return AnimationEventLists::g_actionsStart.contains(action.EventName); +} + +static bool IsExitAction(const ActionEvent& action) noexcept +{ + return AnimationEventLists::g_actionsExit.contains(action.EventName); +} + +static bool ShouldSkipIntermediateAction(const ActionEvent& action) noexcept +{ + return AnimationEventLists::g_actionsSkipIntermediate.contains(action.EventName); +} + +// Either 1) action chain (aka replay cache) will start with an Exit action, +// 2) chain will start with a valid Start action, 3) chain will remain unchanged +static void DropAllBeforeRelevantAction(Vector& actions) noexcept +{ + bool startActionFound = false; + int dropAllUpToIndex = -1; + + for (int i = actions.size() - 1; i >= 0; --i) + { + const auto& action = actions[i]; + + if (IsStartAction(action)) + startActionFound = true; + + // Terminate when an "exit" action is found + if (IsExitAction(action)) + { + dropAllUpToIndex = i; + break; + } + + if (!startActionFound) + continue; + + if (ShouldSkipIntermediateAction(action)) + continue; + if (!IsStartAction(action)) + { + dropAllUpToIndex = i + 1; + break; + } + } + + if (dropAllUpToIndex == -1) + return; + + actions.erase(actions.begin(), actions.begin() + dropAllUpToIndex); +} + +static void UpdateActionsReplayCache(Vector& replayCache, const Vector& recentActions) +{ + for (const auto& action : recentActions) + { + if (AnimationEventLists::g_actionsIgnore.contains(action.EventName)) + continue; + replayCache.push_back(action); + } + + constexpr int kReplayCacheMaxSize = 32; + + if (replayCache.size() > kReplayCacheMaxSize) + replayCache.erase(replayCache.begin(), replayCache.end() - kReplayCacheMaxSize); + + DropAllBeforeRelevantAction(replayCache); +} + void CharacterService::ProcessMovementChanges() const noexcept { static std::chrono::steady_clock::time_point lastSendTimePoint; @@ -838,14 +910,15 @@ void CharacterService::ProcessMovementChanges() const noexcept } } - m_world.view().each( - [](AnimationComponent& animationComponent) + m_world.view().each([](AnimationComponent& animationComponent) { + if (!animationComponent.Actions.empty()) { - if (!animationComponent.Actions.empty()) - animationComponent.LastSerializedAction = animationComponent.Actions[animationComponent.Actions.size() - 1]; + animationComponent.LastSerializedAction = animationComponent.Actions[animationComponent.Actions.size() - 1]; + UpdateActionsReplayCache(animationComponent.ActionsReplayCache, animationComponent.Actions); + } - animationComponent.Actions.clear(); - }); + animationComponent.Actions.clear(); + }); m_world.view().each([](MovementComponent& movementComponent) { movementComponent.Sent = true; });