diff --git a/extensions/include/extensions/multiplayer/characteranimator.h b/extensions/include/extensions/multiplayer/characteranimator.h index 130d1624..13efc11b 100644 --- a/extensions/include/extensions/multiplayer/characteranimator.h +++ b/extensions/include/extensions/multiplayer/characteranimator.h @@ -75,6 +75,12 @@ class CharacterAnimator { // Emote state accessors bool IsEmoteActive() const { return m_emoteActive; } + // Multi-part emote state. Returns true when the player is in any phase of a multi-part + // emote (playing phase 1, frozen at last frame, or playing phase 2). Movement is blocked. + bool IsInMultiPartEmote() const { return m_frozenEmoteId >= 0 || (m_emoteActive && IsMultiPartEmote(m_currentEmoteId)); } + int8_t GetFrozenEmoteId() const { return m_frozenEmoteId; } + void SetFrozenEmoteId(int8_t p_emoteId, LegoROI* p_roi); + // Animation time (needed for vehicle ride tick in ThirdPersonCamera) float GetAnimTime() const { return m_animTime; } void SetAnimTime(float p_time) { m_animTime = p_time; } @@ -84,6 +90,7 @@ class CharacterAnimator { using AnimCache = AnimUtils::AnimCache; AnimCache* GetOrBuildAnimCache(LegoROI* p_roi, const char* p_animName); + void ClearFrozenState(); CharacterAnimatorConfig m_config; @@ -102,8 +109,15 @@ class CharacterAnimator { float m_emoteTime; float m_emoteDuration; bool m_emoteActive; + uint8_t m_currentEmoteId; MxMatrix m_emoteParentTransform; + // Multi-part emote frozen state (-1 = not frozen) + int8_t m_frozenEmoteId; + AnimCache* m_frozenAnimCache; + float m_frozenAnimDuration; + MxMatrix m_frozenParentTransform; + // Click animation tracking (0 = none) MxU32 m_clickAnimObjectId; diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 9a566068..c8638bf1 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -162,9 +162,16 @@ extern const int g_walkAnimCount; extern const char* const g_idleAnimNames[]; extern const int g_idleAnimCount; -extern const char* const g_emoteAnimNames[]; +// Emote animation table: [emoteId][phase]. Phase 0 = primary, phase 1 = phase-2 (nullptr for one-shot). +extern const char* const g_emoteAnims[][2]; extern const int g_emoteAnimCount; +// Returns true if the emote is a multi-part stateful emote (has a phase-2 animation). +inline bool IsMultiPartEmote(uint8_t p_emoteId) +{ + return p_emoteId < g_emoteAnimCount && g_emoteAnims[p_emoteId][1] != nullptr; +} + extern const char* const g_vehicleROINames[VEHICLE_COUNT]; extern const char* const g_rideAnimNames[VEHICLE_COUNT]; extern const char* const g_rideVehicleROINames[VEHICLE_COUNT]; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 46c31b09..8ceb7b40 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -47,6 +47,7 @@ class RemotePlayer { void StopClickAnimation(); bool IsInVehicle() const { return m_animator.IsInVehicle(); } bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; } + bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); } private: const char* GetDisplayActorName() const; diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h index 8b86cda9..1d136865 100644 --- a/extensions/include/extensions/multiplayer/thirdpersoncamera.h +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -38,6 +38,8 @@ class ThirdPersonCamera { void SetWalkAnimId(uint8_t p_walkAnimId); void SetIdleAnimId(uint8_t p_idleAnimId); void TriggerEmote(uint8_t p_emoteId); + bool IsInMultiPartEmote() const; + int8_t GetFrozenEmoteId() const; void SetDisplayActorIndex(uint8_t p_displayActorIndex); uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; } LegoROI* GetDisplayROI() const { return m_displayROI; } diff --git a/extensions/src/multiplayer/characteranimator.cpp b/extensions/src/multiplayer/characteranimator.cpp index 7706352f..2a1b26f4 100644 --- a/extensions/src/multiplayer/characteranimator.cpp +++ b/extensions/src/multiplayer/characteranimator.cpp @@ -20,7 +20,8 @@ using namespace Multiplayer; CharacterAnimator::CharacterAnimator(const CharacterAnimatorConfig& p_config) : m_config(p_config), m_walkAnimId(0), m_idleAnimId(0), m_walkAnimCache(nullptr), m_idleAnimCache(nullptr), m_animTime(0.0f), m_idleTime(0.0f), m_idleAnimTime(0.0f), m_wasMoving(false), m_emoteAnimCache(nullptr), - m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), m_clickAnimObjectId(0), m_rideAnim(nullptr), + m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), m_currentEmoteId(0), m_frozenEmoteId(-1), + m_frozenAnimCache(nullptr), m_frozenAnimDuration(0.0f), m_clickAnimObjectId(0), m_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_currentVehicleType(VEHICLE_NONE), m_nameBubble(nullptr) { @@ -71,10 +72,10 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) bool inVehicle = (m_currentVehicleType != VEHICLE_NONE); bool isMoving = inVehicle || p_isMoving; - // Movement interrupts click animations and emotes - if (isMoving) { + // Movement interrupts click animations and emotes (but not multi-part emotes) + if (isMoving && m_frozenEmoteId < 0) { StopClickAnimation(); - if (m_emoteActive) { + if (m_emoteActive && !IsMultiPartEmote(m_currentEmoteId)) { m_emoteActive = false; m_emoteAnimCache = nullptr; } @@ -104,16 +105,32 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) m_idleAnimTime = 0.0f; } else if (m_emoteActive && m_emoteAnimCache && m_emoteAnimCache->anim && m_emoteAnimCache->roiMap) { - // Emote playback (one-shot) + // Emote playback m_emoteTime += p_deltaTime * 1000.0f; if (m_emoteTime >= m_emoteDuration) { - // Emote completed -- return to stationary flow - m_emoteActive = false; - m_emoteAnimCache = nullptr; - m_wasMoving = false; - m_idleTime = 0.0f; - m_idleAnimTime = 0.0f; + if (IsMultiPartEmote(m_currentEmoteId) && m_frozenEmoteId < 0) { + // Phase 1 completed -> freeze at last frame + m_frozenEmoteId = (int8_t) m_currentEmoteId; + m_frozenAnimCache = m_emoteAnimCache; + m_frozenAnimDuration = m_emoteDuration; + m_emoteActive = false; + if (m_config.saveEmoteTransform) { + m_frozenParentTransform = m_emoteParentTransform; + } + } + else { + if (IsMultiPartEmote(m_currentEmoteId) && m_frozenEmoteId >= 0) { + // Phase 2 completed -> unfreeze + ClearFrozenState(); + } + // Emote completed -- return to stationary flow + m_emoteActive = false; + m_emoteAnimCache = nullptr; + m_wasMoving = false; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + } } else { MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World()); @@ -134,6 +151,24 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) } } } + else if (m_frozenEmoteId >= 0 && m_frozenAnimCache && m_frozenAnimCache->anim && m_frozenAnimCache->roiMap) { + // Frozen at last frame of a multi-part emote's phase-1 animation + MxMatrix transform(m_config.saveEmoteTransform ? m_frozenParentTransform : p_roi->GetLocal2World()); + + LegoTreeNode* root = m_frozenAnimCache->anim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation( + root->GetChild(i), + transform, + (LegoTime) m_frozenAnimDuration, + m_frozenAnimCache->roiMap + ); + } + + if (m_config.saveEmoteTransform) { + p_roi->WrappedSetLocal2WorldWithWorldDataUpdate(m_frozenParentTransform); + } + } else if (m_idleAnimCache && m_idleAnimCache->anim && m_idleAnimCache->roiMap) { // Idle animation if (m_wasMoving) { @@ -199,18 +234,48 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i return; } - // Only play emotes when stationary - if (p_isMoving) { - return; + if (IsMultiPartEmote(p_emoteId)) { + if (m_frozenEmoteId == (int8_t) p_emoteId) { + // Phase 2: play the recovery animation to unfreeze + AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][1]); + if (!cache || !cache->anim) { + return; + } + + StopClickAnimation(); + + m_currentEmoteId = p_emoteId; + m_emoteAnimCache = cache; + m_emoteTime = 0.0f; + m_emoteDuration = (float) cache->anim->GetDuration(); + m_emoteActive = true; + + if (m_config.saveEmoteTransform) { + m_emoteParentTransform = m_frozenParentTransform; + } + return; + } + else if (m_frozenEmoteId >= 0) { + // Already frozen in a different emote, ignore + return; + } + // Phase 1: fall through to play the primary animation + } + else { + // One-shot emote: block if moving or frozen in any multi-part emote + if (p_isMoving || m_frozenEmoteId >= 0) { + return; + } } - AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnimNames[p_emoteId]); + AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][0]); if (!cache || !cache->anim) { return; } StopClickAnimation(); + m_currentEmoteId = p_emoteId; m_emoteAnimCache = cache; m_emoteTime = 0.0f; m_emoteDuration = (float) cache->anim->GetDuration(); @@ -295,6 +360,36 @@ void CharacterAnimator::InitAnimCaches(LegoROI* p_roi) { m_walkAnimCache = GetOrBuildAnimCache(p_roi, g_walkAnimNames[m_walkAnimId]); m_idleAnimCache = GetOrBuildAnimCache(p_roi, g_idleAnimNames[m_idleAnimId]); + + // Rebuild frozen emote cache if the frozen state was set before the ROI was available + // (e.g. state message arrived before world was ready, or world was re-enabled). + if (m_frozenEmoteId >= 0 && !m_frozenAnimCache) { + SetFrozenEmoteId(m_frozenEmoteId, p_roi); + } +} + +void CharacterAnimator::SetFrozenEmoteId(int8_t p_emoteId, LegoROI* p_roi) +{ + if (p_emoteId >= 0 && p_emoteId < g_emoteAnimCount && IsMultiPartEmote((uint8_t) p_emoteId)) { + AnimCache* cache = p_roi ? GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][0]) : nullptr; + m_frozenEmoteId = p_emoteId; + m_frozenAnimCache = cache; + m_frozenAnimDuration = (cache && cache->anim) ? (float) cache->anim->GetDuration() : 0.0f; + m_emoteActive = false; + if (m_config.saveEmoteTransform && p_roi) { + m_frozenParentTransform = p_roi->GetLocal2World(); + } + } + else { + ClearFrozenState(); + } +} + +void CharacterAnimator::ClearFrozenState() +{ + m_frozenEmoteId = -1; + m_frozenAnimCache = nullptr; + m_frozenAnimDuration = 0.0f; } void CharacterAnimator::ClearAnimCaches() @@ -303,6 +398,7 @@ void CharacterAnimator::ClearAnimCaches() m_idleAnimCache = nullptr; m_emoteAnimCache = nullptr; m_emoteActive = false; + ClearFrozenState(); } void CharacterAnimator::ClearAll() @@ -318,6 +414,7 @@ void CharacterAnimator::ResetAnimState() m_idleAnimTime = 0.0f; m_wasMoving = false; m_emoteActive = false; + ClearFrozenState(); } void CharacterAnimator::ApplyIdleFrame0(LegoROI* p_roi) diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 225b0132..44850120 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -330,6 +330,18 @@ void NetworkManager::BroadcastLocalState() m_thirdPersonCamera.GetCustomizeState().Pack(msg.customizeData); msg.customizeFlags = m_localAllowRemoteCustomize ? 0x01 : 0x00; + // Encode multi-part emote frozen state (0x02 = frozen, emote ID in bits 2-4, max 8 emotes) + int8_t frozenId = m_thirdPersonCamera.GetFrozenEmoteId(); + if (frozenId >= 0) { + msg.customizeFlags |= 0x02; + msg.customizeFlags |= (frozenId & 0x07) << 2; + } + + // Zero speed when in any phase of a multi-part emote + if (m_thirdPersonCamera.IsInMultiPartEmote()) { + msg.speed = 0.0f; + } + SendMessage(msg); } @@ -687,7 +699,7 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) it->second->GetCustomizeState(), p_msg.changeType == CHANGE_MOOD ); - if (!it->second->IsMoving()) { + if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote()) { MxU32 clickAnimId = CharacterCustomizer::PlayClickAnimation(it->second->GetROI(), it->second->GetCustomizeState()); it->second->SetClickAnimObjectId(clickAnimId); @@ -719,8 +731,9 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) p_msg.changeType == CHANGE_MOOD ); - // Only play click animation in 3rd person (not visible in 1st person) - if (m_thirdPersonCamera.GetDisplayROI() && !m_thirdPersonCamera.IsInVehicle()) { + // Only play click animation in 3rd person (not visible in 1st person or multi-part emote) + if (m_thirdPersonCamera.GetDisplayROI() && !m_thirdPersonCamera.IsInVehicle() && + !m_thirdPersonCamera.IsInMultiPartEmote()) { MxU32 clickAnimId = CharacterCustomizer::PlayClickAnimation( m_thirdPersonCamera.GetDisplayROI(), m_thirdPersonCamera.GetCustomizeState() diff --git a/extensions/src/multiplayer/protocol.cpp b/extensions/src/multiplayer/protocol.cpp index b83be1e0..66ad151b 100644 --- a/extensions/src/multiplayer/protocol.cpp +++ b/extensions/src/multiplayer/protocol.cpp @@ -26,11 +26,14 @@ const char* const g_idleAnimNames[] = { }; const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]); -const char* const g_emoteAnimNames[] = { - "CNs011xx", // 0: Wave - "CNs012xx", // 1: Hat Tip +// Emote animation table. Each entry is {phase1, phase2}. +// phase2 is nullptr for one-shot emotes; non-null makes it a multi-part stateful emote. +const char* const g_emoteAnims[][2] = { + {"CNs011xx", nullptr}, // 0: Wave (one-shot) + {"CNs012xx", nullptr}, // 1: Hat Tip (one-shot) + {"BNsDis01", "BNsAss01"}, // 2: Disassemble / Reassemble (multi-part) }; -const int g_emoteAnimCount = sizeof(g_emoteAnimNames) / sizeof(g_emoteAnimNames[0]); +const int g_emoteAnimCount = sizeof(g_emoteAnims) / sizeof(g_emoteAnims[0]); // Vehicle model names (LOD names). The helicopter is a compound ROI ("copter") // with no standalone LOD; use its body part instead. diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index ef54d079..11b5cfe0 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -172,6 +172,13 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg) // Update allow remote customize flag m_allowRemoteCustomize = (p_msg.customizeFlags & 0x01) != 0; + // Sync multi-part emote frozen state from remote + bool isFrozen = (p_msg.customizeFlags & 0x02) != 0; + int8_t frozenEmoteId = isFrozen ? (int8_t) ((p_msg.customizeFlags >> 2) & 0x07) : -1; + if (frozenEmoteId != m_animator.GetFrozenEmoteId()) { + m_animator.SetFrozenEmoteId(frozenEmoteId, m_roi); + } + // Swap walk animation if changed if (p_msg.walkAnimId != m_animator.GetWalkAnimId() && p_msg.walkAnimId < g_walkAnimCount) { m_animator.SetWalkAnimId(p_msg.walkAnimId, m_roi); @@ -191,7 +198,12 @@ void RemotePlayer::Tick(float p_deltaTime) UpdateVehicleState(); UpdateTransform(p_deltaTime); - m_animator.Tick(p_deltaTime, m_roi, m_targetSpeed > 0.01f); + + bool isMoving = m_targetSpeed > 0.01f; + if (m_animator.IsInMultiPartEmote()) { + isMoving = false; + } + m_animator.Tick(p_deltaTime, m_roi, isMoving); // Update name bubble position and billboard orientation m_animator.UpdateNameBubble(m_roi); @@ -249,7 +261,11 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId) return; } - m_animator.TriggerEmote(p_emoteId, m_roi, m_targetSpeed > 0.01f); + bool isMoving = m_targetSpeed > 0.01f; + if (m_animator.IsInMultiPartEmote()) { + isMoving = false; + } + m_animator.TriggerEmote(p_emoteId, m_roi, isMoving); } void RemotePlayer::UpdateTransform(float p_deltaTime) diff --git a/extensions/src/multiplayer/thirdpersoncamera.cpp b/extensions/src/multiplayer/thirdpersoncamera.cpp index 476552be..b6f41d42 100644 --- a/extensions/src/multiplayer/thirdpersoncamera.cpp +++ b/extensions/src/multiplayer/thirdpersoncamera.cpp @@ -7,6 +7,7 @@ #include "islepathactor.h" #include "legocameracontroller.h" #include "legocharactermanager.h" +#include "legonavcontroller.h" #include "legopathactor.h" #include "legovideomanager.h" #include "legoworld.h" @@ -294,6 +295,11 @@ void ThirdPersonCamera::Tick(float p_deltaTime) float speed = userActor->GetWorldSpeed(); bool isMoving = SDL_fabsf(speed) > 0.01f; + if (m_animator.IsInMultiPartEmote()) { + isMoving = false; + userActor->SetWorldSpeed(0.0f); + NavController()->SetLinearVel(0.0f); + } m_animator.Tick(p_deltaTime, m_playerROI, isMoving); } @@ -308,6 +314,16 @@ void ThirdPersonCamera::SetIdleAnimId(uint8_t p_idleAnimId) m_animator.SetIdleAnimId(p_idleAnimId, m_active ? m_playerROI : nullptr); } +bool ThirdPersonCamera::IsInMultiPartEmote() const +{ + return m_animator.IsInMultiPartEmote(); +} + +int8_t ThirdPersonCamera::GetFrozenEmoteId() const +{ + return m_animator.GetFrozenEmoteId(); +} + void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId) { if (!m_active) { @@ -319,7 +335,11 @@ void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId) return; } - m_animator.TriggerEmote(p_emoteId, m_playerROI, SDL_fabsf(userActor->GetWorldSpeed()) > 0.01f); + bool isMoving = SDL_fabsf(userActor->GetWorldSpeed()) > 0.01f; + if (m_animator.IsInMultiPartEmote()) { + isMoving = false; + } + m_animator.TriggerEmote(p_emoteId, m_playerROI, isMoving); } void ThirdPersonCamera::ApplyCustomizeChange(uint8_t p_changeType, uint8_t p_partIndex)