diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index c18fee26..cb3918b7 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -55,8 +55,8 @@ if(DOWNLOAD_DEPENDENCIES) include(FetchContent) FetchContent_Populate( libweaver - URL https://github.com/isledecomp/SIEdit/archive/afd4933844b95ef739a7e77b097deb7efe4ec576.tar.gz - URL_MD5 59fd3c36f4f380f730cd9bedfc846397 + URL https://github.com/isledecomp/SIEdit/archive/17c7736a6ff31413f1e74ab4e989011b545b6926.tar.gz + URL_MD5 04edbc974df8884f283d920ded10f1f6 ) add_library(libweaver STATIC ${libweaver_SOURCE_DIR}/lib/core.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dd2b5a30..3f033c76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -534,12 +534,13 @@ if (ISLE_EXTENSIONS) extensions/src/textureloader.cpp # Common shared code - extensions/src/common/animdata.cpp + extensions/src/common/charactertables.cpp extensions/src/common/animutils.cpp extensions/src/common/characteranimator.cpp extensions/src/common/charactercloner.cpp extensions/src/common/charactercustomizer.cpp extensions/src/common/customizestate.cpp + extensions/src/common/pathutils.cpp # Third person camera extension extensions/src/thirdpersoncamera.cpp @@ -549,6 +550,14 @@ if (ISLE_EXTENSIONS) extensions/src/thirdpersoncamera/displayactor.cpp # Multiplayer extension + extensions/src/multiplayer/animation/catalog.cpp + extensions/src/multiplayer/animation/coordinator.cpp + extensions/src/multiplayer/animation/loader.cpp + extensions/src/multiplayer/animation/locationproximity.cpp + extensions/src/multiplayer/animation/sceneplayer.cpp + extensions/src/multiplayer/animation/sessionhost.cpp + extensions/src/multiplayer/animation/audioplayer.cpp + extensions/src/multiplayer/animation/phonemeplayer.cpp extensions/src/multiplayer.cpp extensions/src/multiplayer/namebubblerenderer.cpp extensions/src/multiplayer/networkmanager.cpp diff --git a/LEGO1/lego/legoomni/include/legoanimationmanager.h b/LEGO1/lego/legoomni/include/legoanimationmanager.h index 4990ed60..cb1c6e1b 100644 --- a/LEGO1/lego/legoomni/include/legoanimationmanager.h +++ b/LEGO1/lego/legoomni/include/legoanimationmanager.h @@ -205,6 +205,7 @@ class LegoAnimationManager : public MxCore { private: friend class Multiplayer::NetworkManager; + friend class Multiplayer::Animation::Catalog; void Init(); MxResult FUN_100605e0( diff --git a/extensions/include/extensions/common/animutils.h b/extensions/include/extensions/common/animutils.h index 7685084c..401d24a3 100644 --- a/extensions/include/extensions/common/animutils.h +++ b/extensions/include/extensions/common/animutils.h @@ -58,13 +58,23 @@ struct AnimCache { } }; +// Maps an animation character name to an ROI without renaming the ROI. +// Used for participant ROIs whose real names (e.g. "tp_display") differ +// from the animation tree node names (e.g. "pepper"). +struct ROIAlias { + const char* animName; // name in animation tree (lowercased) + LegoROI* roi; // actual ROI to use +}; + void BuildROIMap( LegoAnim* p_anim, LegoROI* p_rootROI, LegoROI** p_extraROIs, int p_extraROICount, LegoROI**& p_roiMap, - MxU32& p_roiMapSize + MxU32& p_roiMapSize, + const ROIAlias* p_aliases = nullptr, + int p_aliasCount = 0 ); void CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector& p_unmatchedNames); @@ -80,6 +90,16 @@ inline void EnsureROIMapVisibility(LegoROI** p_roiMap, MxU32 p_roiMapSize) } } +// Apply animation transformation to all root children of an animation tree. +void ApplyTree(LegoAnim* p_anim, MxMatrix& p_transform, LegoTime p_time, LegoROI** p_roiMap); + +// Strip trailing digits and underscores from a name to get the LOD base name. +// Mirrors the digit-trimming in LegoAnimPresenter::CreateManagedActors/CreateSceneROIs. +std::string TrimLODSuffix(const std::string& p_name); + +// Maps animation tree node names to actual LOD names when they differ. +const char* ResolvePropLODName(const char* p_nodeName); + // Flip a matrix from forward-z to backward-z (or vice versa) in place. inline void FlipMatrixDirection(MxMatrix& p_mat) { diff --git a/extensions/include/extensions/common/characteranimator.h b/extensions/include/extensions/common/characteranimator.h index 9419b533..604bf95b 100644 --- a/extensions/include/extensions/common/characteranimator.h +++ b/extensions/include/extensions/common/characteranimator.h @@ -1,7 +1,7 @@ #pragma once -#include "extensions/common/animdata.h" #include "extensions/common/animutils.h" +#include "extensions/common/charactertables.h" #include "mxgeometry/mxmatrix.h" #include "mxtypes.h" diff --git a/extensions/include/extensions/common/animdata.h b/extensions/include/extensions/common/charactertables.h similarity index 95% rename from extensions/include/extensions/common/animdata.h rename to extensions/include/extensions/common/charactertables.h index 803640d2..9f7ab696 100644 --- a/extensions/include/extensions/common/animdata.h +++ b/extensions/include/extensions/common/charactertables.h @@ -11,7 +11,7 @@ namespace Extensions namespace Common { -// Animation and vehicle tables (defined in animdata.cpp) +// Animation and vehicle tables (defined in charactertables.cpp) extern const char* const g_walkAnimNames[]; extern const int g_walkAnimCount; diff --git a/extensions/include/extensions/common/pathutils.h b/extensions/include/extensions/common/pathutils.h new file mode 100644 index 00000000..ba3e7eec --- /dev/null +++ b/extensions/include/extensions/common/pathutils.h @@ -0,0 +1,17 @@ +#pragma once + +#include "mxstring.h" + +namespace Extensions +{ +namespace Common +{ + +// Resolve a relative game path (e.g. "\\lego\\scripts\\isle\\isle.si") +// by trying the HD path first, then falling back to CD. +// Returns true if the file exists at either location, with the +// filesystem-mapped result in p_outPath. +bool ResolveGamePath(const char* p_relativePath, MxString& p_outPath); + +} // namespace Common +} // namespace Extensions diff --git a/extensions/include/extensions/fwd.h b/extensions/include/extensions/fwd.h index aed06b0a..c10c809d 100644 --- a/extensions/include/extensions/fwd.h +++ b/extensions/include/extensions/fwd.h @@ -20,6 +20,11 @@ namespace Multiplayer { class NetworkManager; class WorldStateSync; +namespace Animation +{ +class Catalog; +class Controller; +} // namespace Animation } // namespace Multiplayer #endif // EXTENSIONS_FWD_H diff --git a/extensions/include/extensions/multiplayer/animation/audioplayer.h b/extensions/include/extensions/multiplayer/animation/audioplayer.h new file mode 100644 index 00000000..6154ce9c --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/audioplayer.h @@ -0,0 +1,33 @@ +#pragma once + +#include "extensions/multiplayer/animation/loader.h" + +#include +#include + +class LegoCacheSound; + +namespace Multiplayer::Animation +{ + +class AudioPlayer { +public: + // Create LegoCacheSound objects from SceneAnimData's audio tracks + void Init(const std::vector& p_tracks); + + // Start sounds whose time offset has been reached + void Tick(float p_elapsedMs, const char* p_roiName); + + // Stop and delete all sounds + void Cleanup(); + +private: + struct ActiveSound { + LegoCacheSound* sound; + uint32_t timeOffset; + bool started; + }; + std::vector m_activeSounds; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/catalog.h b/extensions/include/extensions/multiplayer/animation/catalog.h new file mode 100644 index 00000000..11f536b2 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/catalog.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +class LegoAnimationManager; +struct AnimInfo; + +namespace Multiplayer::Animation +{ + +enum AnimCategory : uint8_t { + e_npcAnim, // characterIndex >= 0 && location == -1 + e_camAnim, // characterIndex >= 0 && location >= 0 + e_otherAnim // characterIndex < 0 (ambient, non-character) +}; + +// Number of core playable characters (Pepper, Mama, Papa, Nick, Laura) = g_characters indices 0-4 +static const int8_t CORE_CHARACTER_COUNT = 5; + +// Spectator mask with all core characters enabled +static const uint8_t ALL_CORE_ACTORS_MASK = (1 << CORE_CHARACTER_COUNT) - 1; + +// Sentinel value for "no animation selected" +static const uint16_t ANIM_INDEX_NONE = 0xFFFF; + +// Extract the character indices from a performer bitmask. +std::vector GetPerformerIndices(uint64_t p_performerMask); + +struct CatalogEntry { + uint16_t animIndex; // Index into LegoAnimationManager::m_anims[] + AnimCategory category; + uint8_t spectatorMask; // Which core actors can trigger (bit0=Pepper..bit4=Laura) + uint64_t performerMask; // Bitmask of g_characters[] indices that appear as character models + int16_t location; // -1 = anywhere, >= 0 = specific location + int8_t characterIndex; // Primary character index into g_characters[] + uint8_t modelCount; // Number of models in animation +}; + +class Catalog { +public: + void Refresh(LegoAnimationManager* p_am); + + const AnimInfo* GetAnimInfo(uint16_t p_animIndex) const; + const CatalogEntry* FindEntry(uint16_t p_animIndex) const; + + // All non-otherAnim entries at a location (-1 = NPC anims, >= 0 = location-bound) + std::vector GetAnimationsAtLocation(int16_t p_location) const; + + // Check if a player can fill any role (spectator or participant) in this animation. + // Accepts a display actor index (converted to g_characters index internally). + bool CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActorIndex) const; + + // Same check but using a g_characters index directly. + static bool CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex); + + // Check if a set of character indices can collectively trigger this animation. + // p_filledPerformers: bitmask of which performer bits in performerMask are covered. + // p_spectatorFilled: whether a valid spectator was found among unassigned players. + bool CanTrigger( + const CatalogEntry* p_entry, + const int8_t* p_charIndices, + uint8_t p_count, + uint64_t* p_filledPerformers, + bool* p_spectatorFilled + ) const; + + // Check if the spectator mask allows this character to spectate. + // Does NOT check performer exclusion — caller must do that if needed. + static bool CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex); + + // Convert a display actor index to the g_characters[] index used by animations. + // Returns -1 if no match. + static int8_t DisplayActorToCharacterIndex(uint8_t p_displayActorIndex); + +private: + std::vector m_entries; + std::map> m_locationIndex; // location ID → indices into m_entries + AnimInfo* m_animsBase; + uint16_t m_animCount; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/coordinator.h b/extensions/include/extensions/multiplayer/animation/coordinator.h new file mode 100644 index 00000000..ffd1b5c5 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/coordinator.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include + +namespace Multiplayer::Animation +{ + +class Catalog; +struct CatalogEntry; + +enum class CoordinationState : uint8_t { + e_idle, + e_interested, + e_countdown, + e_playing +}; + +struct SlotInfo { + // Character names that can fill this slot. + // Performer slots: always 1 name (the specific character). + // Spectator slot: ["any"] if ALL_CORE_ACTORS_MASK, otherwise the specific allowed names. + std::vector names; + bool filled; +}; + +struct EligibilityInfo { + uint16_t animIndex; + bool eligible; // All requirements met: at location and all roles filled + bool atLocation; // At the right location (or location == -1) + const CatalogEntry* entry; // Pointer into catalog (valid until next Refresh) + std::vector slots; // All role slots (performers + spectator), filled status each +}; + +struct SessionView { + CoordinationState state; + uint16_t countdownMs; + uint32_t countdownEndTime; // SDL_GetTicks() timestamp when countdown expires (client-side) + uint32_t peerSlots[8]; // peerId per slot (matches AnimUpdateMsg layout) + uint8_t slotCount; +}; + +class Coordinator { +public: + Coordinator(); + + void SetCatalog(const Catalog* p_catalog); + + CoordinationState GetState() const { return m_state; } + uint16_t GetCurrentAnimIndex() const { return m_currentAnimIndex; } + + void SetLocalPeerId(uint32_t p_peerId); + void SetInterest(uint16_t p_animIndex); + void ClearInterest(); + + // Compute eligibility for animations at a location. + // p_locationChars: local player + remote players at the same location (for cam anims). + // p_proximityChars: local player + remote players within proximity (for NPC anims). + std::vector ComputeEligibility( + int16_t p_location, + const int8_t* p_locationChars, + uint8_t p_locationCount, + const int8_t* p_proximityChars, + uint8_t p_proximityCount + ) const; + + // Auto-clear interest if current animation is not available at the new location. + void OnLocationChanged(int16_t p_location, const Catalog* p_catalog); + + void Reset(); + + // Apply authoritative session state from host + void ApplySessionUpdate( + uint16_t p_animIndex, + uint8_t p_state, + uint16_t p_countdownMs, + const uint32_t p_slots[8], + uint8_t p_slotCount + ); + + // Apply animation start from host + void ApplyAnimStart(uint16_t p_animIndex); + + // Get session view for an animation (nullptr if no session) + const SessionView* GetSessionView(uint16_t p_animIndex) const; + + // Check if local player is in a session for this animation + bool IsLocalPlayerInSession(uint16_t p_animIndex) const; + +private: + const Catalog* m_catalog; + CoordinationState m_state; + uint16_t m_currentAnimIndex; + uint32_t m_localPeerId; + + // When true, a cancel has been sent to the host but not yet confirmed. + // Prevents stale session updates from re-enrolling the local player. + bool m_cancelPending; + + // Known sessions from host broadcasts + std::map m_sessions; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/loader.h b/extensions/include/extensions/multiplayer/animation/loader.h new file mode 100644 index 00000000..df566aab --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/loader.h @@ -0,0 +1,114 @@ +#pragma once + +#include "mxcriticalsection.h" +#include "mxthread.h" +#include "mxwavepresenter.h" + +#include +#include +#include +#include +#include + +struct FLIC_HEADER; +class LegoAnim; + +namespace si +{ +class File; +class Interleaf; +class Object; +} // namespace si + +namespace Multiplayer::Animation +{ + +struct SceneAnimData { + LegoAnim* anim; + float duration; + + struct AudioTrack { + MxU8* pcmData; + MxU32 pcmDataSize; + MxWavePresenter::WaveFormat format; + std::string mediaSrcPath; + int32_t volume; + uint32_t timeOffset; + }; + std::vector audioTracks; + + struct PhonemeTrack { + FLIC_HEADER* flcHeader; + std::vector> frameData; + uint32_t timeOffset; + std::string roiName; + uint16_t width, height; + }; + std::vector phonemeTracks; + + // Action transform from SI metadata (location/direction/up) + struct { + float location[3]; + float direction[3]; + float up[3]; + bool valid; + } actionTransform; + + std::vector ptAtCamNames; // ROI names from PTATCAM directive + bool hideOnStop; + + SceneAnimData(); + ~SceneAnimData(); + + SceneAnimData(const SceneAnimData&) = delete; + SceneAnimData& operator=(const SceneAnimData&) = delete; + SceneAnimData(SceneAnimData&& p_other) noexcept; + SceneAnimData& operator=(SceneAnimData&& p_other) noexcept; + +private: + void ReleaseTracks(); +}; + +// Loads animation data from ISLE.SI on demand, bypassing the streaming pipeline. +// Reads only the RIFF header + offset table on first open, then seeks to +// individual objects as requested. +class Loader { +public: + Loader(); + ~Loader(); + + bool OpenSI(); + SceneAnimData* EnsureCached(uint32_t p_objectId); + void PreloadAsync(uint32_t p_objectId); + +private: + class PreloadThread : public MxThread { + public: + PreloadThread(Loader* p_loader, uint32_t p_objectId); + MxResult Run() override; + + private: + Loader* m_loader; + uint32_t m_objectId; + }; + + static bool OpenSIHeaderOnly(const char* p_siPath, si::File*& p_file, si::Interleaf*& p_interleaf); + bool ReadObject(uint32_t p_objectId); + static bool ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data); + static bool ParseSoundChild(si::Object* p_child, SceneAnimData& p_data); + static bool ParsePhonemeChild(si::Object* p_child, SceneAnimData& p_data); + static bool ParseComposite(si::Object* p_composite, SceneAnimData& p_data); + void CleanupPreloadThread(); + + si::File* m_siFile; + si::Interleaf* m_interleaf; + bool m_siReady; + std::map m_cache; + MxCriticalSection m_cacheCS; + + PreloadThread* m_preloadThread; + uint32_t m_preloadObjectId; + std::atomic m_preloadDone; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/locationproximity.h b/extensions/include/extensions/multiplayer/animation/locationproximity.h new file mode 100644 index 00000000..32b1a357 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/locationproximity.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace Multiplayer::Animation +{ + +static constexpr float NPC_ANIM_PROXIMITY = 15.0f; + +class LocationProximity { +public: + LocationProximity(); + + // Returns true if nearest location changed since last call + bool Update(float p_x, float p_z); + + int16_t GetNearestLocation() const { return m_nearestLocation; } + float GetNearestDistance() const { return m_nearestDistance; } + + void SetRadius(float p_radius) { m_radius = p_radius; } + float GetRadius() const { return m_radius; } + void Reset(); + + // Static version for computing any position's nearest location + static int16_t ComputeNearest(float p_x, float p_z, float p_radius); + +private: + int16_t m_nearestLocation; + float m_nearestDistance; + float m_radius; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/phonemeplayer.h b/extensions/include/extensions/multiplayer/animation/phonemeplayer.h new file mode 100644 index 00000000..b8d2cb4a --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/phonemeplayer.h @@ -0,0 +1,33 @@ +#pragma once + +#include "extensions/multiplayer/animation/loader.h" + +#include +#include + +class LegoROI; +class LegoTextureInfo; +class MxBitmap; + +namespace Multiplayer::Animation +{ + +struct PhonemeState { + LegoROI* targetROI; + LegoTextureInfo* originalTexture; + LegoTextureInfo* cachedTexture; + MxBitmap* bitmap; + int32_t currentFrame; +}; + +class PhonemePlayer { +public: + void Init(const std::vector& p_tracks, LegoROI** p_roiMap, MxU32 p_roiMapSize); + void Tick(float p_elapsedMs, const std::vector& p_tracks); + void Cleanup(); + +private: + std::vector m_states; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/sceneplayer.h b/extensions/include/extensions/multiplayer/animation/sceneplayer.h new file mode 100644 index 00000000..07cd3e87 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sceneplayer.h @@ -0,0 +1,97 @@ +#pragma once + +#include "extensions/multiplayer/animation/audioplayer.h" +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/loader.h" +#include "extensions/multiplayer/animation/phonemeplayer.h" +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include +#include +#include + +class LegoROI; +struct AnimInfo; + +namespace Multiplayer::Animation +{ + +// A participant (local or remote player) whose ROI is borrowed during animation +struct ParticipantROI { + LegoROI* roi; + LegoROI* vehicleROI; // Ride vehicle ROI (bike/board/moto), or nullptr + MxMatrix savedTransform; + std::string savedName; + int8_t charIndex; // g_characters[] index, or -1 for spectator + + bool IsSpectator() const { return charIndex < 0; } +}; + +class ScenePlayer { +public: + ScenePlayer(); + ~ScenePlayer(); + + // When p_observerMode is false, p_participants[0] must be the local player. + // When p_observerMode is true, participants are only remote performers (no local player). + void Play( + const AnimInfo* p_animInfo, + AnimCategory p_category, + const ParticipantROI* p_participants, + uint8_t p_participantCount, + bool p_observerMode = false + ); + void Tick(); + void Stop(); + bool IsPlaying() const { return m_playing; } + + void PreloadAsync(uint32_t p_objectId) { m_loader.PreloadAsync(p_objectId); } + +private: + void ComputeRebaseMatrix(); + void SetupROIs(const AnimInfo* p_animInfo); + void ResolvePtAtCamROIs(); + void ApplyPtAtCam(); + void CleanupProps(); + + // Sub-components + Loader m_loader; + AudioPlayer m_audioPlayer; + PhonemePlayer m_phonemePlayer; + + // Playback state + bool m_playing; + bool m_rebaseComputed; + uint64_t m_startTime; + SceneAnimData* m_currentData; + AnimCategory m_category; + MxMatrix m_animPose0; + MxMatrix m_rebaseMatrix; + + // Participants (local player at index 0, remote players after) + std::vector m_participants; + + // Root performer ROI (rebase anchor for NPC anims) + LegoROI* m_animRootROI; + + // Vehicle ROI borrowed from a participant during playback + LegoROI* m_vehicleROI; + + // Player's ride vehicle hidden during cam_anim (not borrowed, just hidden) + LegoROI* m_hiddenVehicleROI; + + // ROI map for skeletal animation + LegoROI** m_roiMap; + MxU32 m_roiMapSize; + + // Props created for the animation (cloned characters and prop models) + std::vector m_propROIs; + + bool m_hasCamAnim; + bool m_observerMode; + std::vector m_ptAtCamROIs; + bool m_hideOnStop; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/sessionhost.h b/extensions/include/extensions/multiplayer/animation/sessionhost.h new file mode 100644 index 00000000..228179b2 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sessionhost.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +namespace Multiplayer::Animation +{ + +class Catalog; +struct CatalogEntry; +enum class CoordinationState : uint8_t; + +struct SessionSlot { + uint32_t peerId; // 0 = unfilled + int8_t charIndex; // g_characters index, or -1 for spectator + + bool IsSpectator() const { return charIndex < 0; } +}; + +struct AnimSession { + uint16_t animIndex; + CoordinationState state; + std::vector slots; + uint32_t countdownEndTime; // SDL_GetTicks timestamp when countdown expires +}; + +class SessionHost { +public: + void SetCatalog(const Catalog* p_catalog); + + bool HandleInterest( + uint32_t p_peerId, + uint16_t p_animIndex, + uint8_t p_displayActorIndex, + std::vector& p_changedAnims); + bool HandleCancel(uint32_t p_peerId, std::vector& p_changedAnims); + bool HandlePlayerRemoved(uint32_t p_peerId, std::vector& p_changedAnims); + + // Returns animIndex of session ready to play, or ANIM_INDEX_NONE + uint16_t Tick(uint32_t p_now); + + void StartCountdown(uint16_t p_animIndex); + void RevertCountdown(uint16_t p_animIndex); + + void Reset(); + void EraseSession(uint16_t p_animIndex); + + const AnimSession* FindSession(uint16_t p_animIndex) const; + const std::map& GetSessions() const; + bool AreAllSlotsFilled(uint16_t p_animIndex) const; + + static uint16_t ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now); + + // Reconstruct slot charIndex assignments from CatalogEntry::performerMask. + // Same iteration order as CreateSession — deterministic across all clients. + static std::vector ComputeSlotCharIndices(const CatalogEntry* p_entry); + + bool HasCountdownSession() const; + +private: + AnimSession CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex); + bool TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex); + bool AllSlotsFilled(const AnimSession& p_session) const; + void RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector& p_changedAnims); + void RemovePlayerFromSessions( + uint32_t p_peerId, + bool p_includePlayingSessions, + std::vector& p_changedAnims); + + const Catalog* m_catalog = nullptr; + std::map m_sessions; + + static const uint32_t COUNTDOWN_DURATION_MS = 4000; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 9e533c60..3d3ad76f 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -1,5 +1,10 @@ #pragma once +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/coordinator.h" +#include "extensions/multiplayer/animation/locationproximity.h" +#include "extensions/multiplayer/animation/sceneplayer.h" +#include "extensions/multiplayer/animation/sessionhost.h" #include "extensions/multiplayer/networktransport.h" #include "extensions/multiplayer/platformcallbacks.h" #include "extensions/multiplayer/protocol.h" @@ -75,6 +80,11 @@ class NetworkManager : public MxCore { void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); } void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); } void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.store(true, std::memory_order_relaxed); } + void RequestSetAnimInterest(int32_t p_animIndex) + { + m_pendingAnimInterest.store(p_animIndex, std::memory_order_relaxed); + } + void RequestCancelAnimInterest() { m_pendingAnimCancel.store(true, std::memory_order_relaxed); } bool IsInIsleWorld() const { return m_inIsleWorld; } bool GetShowNameBubbles() const { return m_showNameBubbles; } @@ -83,6 +93,10 @@ class NetworkManager : public MxCore { bool IsClonedCharacter(const char* p_name) const; void SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex); + // Stop any playing animation and release its resources. + // Must be called before the display ROI is destroyed. + void StopAnimation(); + void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); void OnBeforeSaveLoad(); @@ -116,6 +130,26 @@ class NetworkManager : public MxCore { void HandleEmote(const EmoteMsg& p_msg); void HandleCustomize(const CustomizeMsg& p_msg); + // Animation coordination handlers + void HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex); + void HandleAnimCancel(uint32_t p_peerId); + void HandleAnimUpdate(const AnimUpdateMsg& p_msg); + void HandleAnimStart(const AnimStartMsg& p_msg); + void HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession); + AnimUpdateMsg BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target); + void BroadcastAnimUpdate(uint16_t p_animIndex); + void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId); + void BroadcastAnimStart(uint16_t p_animIndex); + int16_t GetPeerLocation(uint32_t p_peerId) const; + bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; + bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; + bool ValidateSessionLocations(uint16_t p_animIndex); + + void ResetAnimationState(); + void CancelLocalAnimInterest(); + void BroadcastChangedSessions(const std::vector& p_changedAnims); + void TickHostSessions(); + void ProcessPendingRequests(); void RemoveRemotePlayer(uint32_t p_peerId); void RemoveAllRemotePlayers(); @@ -126,6 +160,7 @@ class NetworkManager : public MxCore { void NotifyPlayerCountChanged(); void EnforceDisableNPCs(); + void PushAnimationState(); // Serialize and send a fixed-size message via the transport template @@ -153,12 +188,31 @@ class NetworkManager : public MxCore { std::atomic m_pendingIdleAnim; std::atomic m_pendingEmote; std::atomic m_pendingToggleAllowCustomize; + std::atomic m_pendingAnimInterest; + std::atomic m_pendingAnimCancel; bool m_disableAllNPCs; bool m_showNameBubbles; bool m_lastCameraEnabled; bool m_wasInRestrictedArea; + // NPC animation playback + Multiplayer::Animation::Catalog m_animCatalog; + Multiplayer::Animation::ScenePlayer m_scenePlayer; + Multiplayer::Animation::LocationProximity m_locationProximity; + Multiplayer::Animation::Coordinator m_animCoordinator; + Multiplayer::Animation::SessionHost m_animSessionHost; + int32_t m_localPendingAnimInterest; + uint16_t m_playingAnimIndex; + + void TickAnimation(); + void StopScenePlayback(bool p_unlockRemotes); + + // Animation state push + bool m_animStateDirty; + bool m_animInterestDirty; + uint32_t m_lastAnimPushTime; + ConnectionState m_connectionState; bool m_wasRejected; std::string m_roomId; @@ -166,11 +220,12 @@ class NetworkManager : public MxCore { uint32_t m_reconnectDelay; uint32_t m_nextReconnectTime; - static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz - static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout + static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz + static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000; static const uint32_t RECONNECT_MAX_DELAY_MS = 30000; static const uint32_t RECONNECT_MAX_ATTEMPTS = 10; + static const uint32_t ANIM_PUSH_COOLDOWN_MS = 250; // max ~4Hz for movement-based changes }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h index 7004e948..da14359e 100644 --- a/extensions/include/extensions/multiplayer/platformcallbacks.h +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace Multiplayer { @@ -27,6 +29,10 @@ class PlatformCallbacks { // Called when the connection status changes (connected, reconnecting, failed). virtual void OnConnectionStatusChanged(int p_status) = 0; + + // Called when animation eligibility state changes (location change, player join/leave, etc.). + // p_json = JSON payload with location, coordinator state, and per-animation slot fill status. + virtual void OnAnimationsAvailable(const char* p_json) = 0; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h index 34e4ac19..4e95ecd0 100644 --- a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -14,6 +14,7 @@ class EmscriptenCallbacks : public PlatformCallbacks { void OnNameBubblesChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override; void OnConnectionStatusChanged(int p_status) override; + void OnAnimationsAvailable(const char* p_json) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h index fd5428a0..7049ac02 100644 --- a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h @@ -14,6 +14,7 @@ class NativeCallbacks : public PlatformCallbacks { void OnNameBubblesChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override; void OnConnectionStatusChanged(int p_status) override; + void OnAnimationsAvailable(const char* p_json) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 056e40f2..737057a5 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -25,6 +25,10 @@ enum MessageType : uint8_t { MSG_WORLD_EVENT_REQUEST = 8, MSG_EMOTE = 9, MSG_CUSTOMIZE = 10, + MSG_ANIM_INTEREST = 11, + MSG_ANIM_CANCEL = 12, + MSG_ANIM_UPDATE = 13, + MSG_ANIM_START = 14, MSG_ASSIGN_ID = 0xFF }; @@ -150,6 +154,39 @@ struct CustomizeMsg { uint8_t partIndex; // Body part for color changes (0-9), 0xFF otherwise }; +// Client -> Host: express interest in an animation slot +struct AnimInterestMsg { + MessageHeader header; + uint16_t animIndex; + uint8_t displayActorIndex; +}; + +// Client -> Host: cancel interest in current animation +struct AnimCancelMsg { + MessageHeader header; +}; + +// Per-slot assignment in AnimUpdateMsg +struct AnimSlotAssignment { + uint32_t peerId; // 0 = unfilled +}; + +// Host -> All: authoritative session state update +struct AnimUpdateMsg { + MessageHeader header; + uint16_t animIndex; + uint8_t state; // CoordinationState (0=cleared, 1=gathering, 2=countdown, 3=playing) + uint16_t countdownMs; // Remaining countdown ms (0 if not counting) + uint8_t slotCount; // Number of valid slot entries + AnimSlotAssignment slots[8]; // peerId per slot (0 = unfilled) +}; + +// Host -> All: animation playback trigger +struct AnimStartMsg { + MessageHeader header; + uint16_t animIndex; +}; + #pragma pack(pop) using Extensions::Common::IsValidActorId; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index d201a896..4c0fe269 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -36,6 +36,8 @@ class RemotePlayer { bool IsSpawned() const { return m_spawned; } bool IsVisible() const { return m_visible; } int8_t GetWorldId() const { return m_targetWorldId; } + int16_t GetNearestLocation() const { return m_nearestLocation; } + void SetNearestLocation(int16_t p_location) { m_nearestLocation = p_location; } uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } void SetVisible(bool p_visible); void TriggerEmote(uint8_t p_emoteId); @@ -48,9 +50,13 @@ class RemotePlayer { void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); } void StopClickAnimation(); bool IsInVehicle() const { return m_animator.IsInVehicle(); } + LegoROI* GetRideVehicleROI() const { return m_animator.GetRideVehicleROI(); } bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; } bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); } + void SetAnimationLocked(bool p_locked) { m_animationLocked = p_locked; } + bool IsAnimationLocked() const { return m_animationLocked; } + private: const char* GetDisplayActorName() const; void UpdateTransform(float p_deltaTime); @@ -76,6 +82,7 @@ class RemotePlayer { int8_t m_targetWorldId; uint32_t m_lastUpdateTime; bool m_hasReceivedUpdate; + int16_t m_nearestLocation; float m_currentPosition[3]; float m_currentDirection[3]; @@ -89,6 +96,7 @@ class RemotePlayer { Extensions::Common::CustomizeState m_customizeState; bool m_allowRemoteCustomize; + bool m_animationLocked; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/thirdpersoncamera/controller.h b/extensions/include/extensions/thirdpersoncamera/controller.h index 602f2655..f04a3190 100644 --- a/extensions/include/extensions/thirdpersoncamera/controller.h +++ b/extensions/include/extensions/thirdpersoncamera/controller.h @@ -8,6 +8,7 @@ #include #include +#include class IslePathActor; class LegoNavController; @@ -56,6 +57,23 @@ class Controller { void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); } void StopClickAnimation(); bool IsInVehicle() const { return m_animator.IsInVehicle(); } + LegoROI* GetRideVehicleROI() const { return m_animator.GetRideVehicleROI(); } + + // Signal that an external animation is active. + // p_lockDisplay: true if the display ROI is being driven by the animation (performer), + // false if the local player is just spectating (idle anim continues). + // p_onStop is called before the display ROI is destroyed (Deactivate/OnWorldDisabled). + void SetAnimPlaying( + bool p_animPlaying, + bool p_lockDisplay = true, + std::function p_animStopCallback = nullptr + ) + { + m_animPlaying = p_animPlaying; + m_animLockDisplay = p_animPlaying && p_lockDisplay; + m_animStopCallback = p_animPlaying ? std::move(p_animStopCallback) : nullptr; + } + bool IsAnimPlaying() const { return m_animPlaying; } void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -119,6 +137,9 @@ class Controller { bool m_enabled; bool m_active; bool m_pendingWorldTransition; + bool m_animPlaying; + bool m_animLockDisplay; + std::function m_animStopCallback; bool m_lmbForwardEngaged; LegoROI* m_playerROI; }; diff --git a/extensions/src/common/animutils.cpp b/extensions/src/common/animutils.cpp index fc2319ff..cf95c46a 100644 --- a/extensions/src/common/animutils.cpp +++ b/extensions/src/common/animutils.cpp @@ -7,6 +7,7 @@ #include "misc/legotree.h" #include "roi/legoroi.h" +#include #include #include @@ -14,12 +15,33 @@ using namespace Extensions::Common; // Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime // via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes. +// +// Intentional divergences from LegoAnimPresenter::BuildROIMap (legoanimpresenter.cpp:413-530): +// 1. No variable substitution -- we bypass the streaming pipeline, so the variable +// table lacks our entries. Direct name comparison instead. +// 2. *-prefixed nodes search extraROIs -- the original's GetActorName() depends on +// presenter action context (m_action->GetUnknown24()). We search created extra +// ROIs directly. +// 3. No LegoAnimStructMap dedup -- sequential indices, functionally correct. +// Look up an animation node name in the alias map (case-insensitive). +static LegoROI* FindAlias(const char* p_name, const AnimUtils::ROIAlias* p_aliases, int p_aliasCount) +{ + for (int i = 0; i < p_aliasCount; i++) { + if (p_aliases[i].animName && !SDL_strcasecmp(p_name, p_aliases[i].animName)) { + return p_aliases[i].roi; + } + } + return nullptr; +} + static void AssignROIIndices( LegoTreeNode* p_node, LegoROI* p_parentROI, LegoROI* p_rootROI, LegoROI** p_extraROIs, int p_extraROICount, + const AnimUtils::ROIAlias* p_aliases, + int p_aliasCount, MxU32& p_nextIndex, std::vector& p_entries, bool& p_rootClaimed @@ -34,27 +56,50 @@ static void AssignROIIndices( if (*name == '*' || p_parentROI == nullptr) { roi = p_rootROI; - if (!p_rootClaimed) { - matchedROI = p_rootROI; + + const char* searchName = (*name == '*') ? name + 1 : name; + bool matchedExtra = false; + + // Check aliases first (participant ROIs mapped by character name). + // Claiming root prevents subsequent sibling nodes from also claiming it. + matchedROI = FindAlias(searchName, p_aliases, p_aliasCount); + if (matchedROI) { + roi = matchedROI; + matchedExtra = true; p_rootClaimed = true; } - else if (*name == '*' && p_extraROICount > 0) { - // Subsequent *-prefixed node: search extra ROIs by stripped name. - // FindChildROI checks self first, then children recursively. - const char* stripped = name + 1; + + // Then check extra ROIs by name. + // This handles cases like BIKESY appearing before SY in the tree: + // BIKESY should match the vehicle extra, not claim the root. + if (!matchedExtra && p_extraROICount > 0) { for (int e = 0; e < p_extraROICount; e++) { - matchedROI = p_extraROIs[e]->FindChildROI(stripped, p_extraROIs[e]); + matchedROI = p_extraROIs[e]->FindChildROI(searchName, p_extraROIs[e]); if (matchedROI != nullptr) { + roi = matchedROI; + matchedExtra = true; break; } } } + + if (!matchedExtra) { + if (!p_rootClaimed) { + matchedROI = p_rootROI; + p_rootClaimed = true; + } + } } else { matchedROI = p_parentROI->FindChildROI(name, p_parentROI); if (matchedROI == nullptr) { - // FindChildROI checks self first, so this handles both - // direct name matches and child searches on extra ROIs. + // Check aliases — also update roi so children resolve against the alias ROI + matchedROI = FindAlias(name, p_aliases, p_aliasCount); + if (matchedROI) { + roi = matchedROI; + } + } + if (matchedROI == nullptr) { for (int e = 0; e < p_extraROICount; e++) { matchedROI = p_extraROIs[e]->FindChildROI(name, p_extraROIs[e]); if (matchedROI != nullptr) { @@ -62,6 +107,36 @@ static void AssignROIIndices( } } } + // Mirrors original game (legoanimpresenter.cpp:486-490): + // If FindChildROI fails, the node might be a top-level actor that isn't + // a child of the current parent. Re-run this node with p_parentROI=NULL + // so it enters the root-claiming / top-level search path instead. + if (matchedROI == nullptr) { + bool isTopLevel = false; + // Check aliases for top-level match + if (FindAlias(name, p_aliases, p_aliasCount) != nullptr) { + isTopLevel = true; + } + if (!isTopLevel && !p_rootClaimed && p_rootROI->GetName() && + !SDL_strcasecmp(name, p_rootROI->GetName())) { + isTopLevel = true; + } + if (!isTopLevel) { + for (int e = 0; e < p_extraROICount; e++) { + if (p_extraROIs[e]->GetName() && !SDL_strcasecmp(name, p_extraROIs[e]->GetName())) { + isTopLevel = true; + break; + } + } + } + if (isTopLevel) { + AssignROIIndices( + p_node, nullptr, p_rootROI, p_extraROIs, p_extraROICount, + p_aliases, p_aliasCount, p_nextIndex, p_entries, p_rootClaimed + ); + return; + } + } } if (matchedROI != nullptr) { @@ -81,6 +156,8 @@ static void AssignROIIndices( p_rootROI, p_extraROIs, p_extraROICount, + p_aliases, + p_aliasCount, p_nextIndex, p_entries, p_rootClaimed @@ -94,7 +171,9 @@ void AnimUtils::BuildROIMap( LegoROI** p_extraROIs, int p_extraROICount, LegoROI**& p_roiMap, - MxU32& p_roiMapSize + MxU32& p_roiMapSize, + const ROIAlias* p_aliases, + int p_aliasCount ) { if (!p_anim || !p_rootROI) { @@ -109,7 +188,7 @@ void AnimUtils::BuildROIMap( MxU32 nextIndex = 1; std::vector entries; bool rootClaimed = false; - AssignROIIndices(root, nullptr, p_rootROI, p_extraROIs, p_extraROICount, nextIndex, entries, rootClaimed); + AssignROIIndices(root, nullptr, p_rootROI, p_extraROIs, p_extraROICount, p_aliases, p_aliasCount, nextIndex, entries, rootClaimed); if (entries.empty()) { return; @@ -223,3 +302,43 @@ void AnimUtils::CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std: bool rootClaimed = false; CollectUnmatchedNodesRecursive(root, nullptr, p_rootROI, p_unmatchedNames, rootClaimed); } + +void AnimUtils::ApplyTree(LegoAnim* p_anim, MxMatrix& p_transform, LegoTime p_time, LegoROI** p_roiMap) +{ + LegoTreeNode* root = p_anim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation(root->GetChild(i), p_transform, p_time, p_roiMap); + } +} + +std::string AnimUtils::TrimLODSuffix(const std::string& p_name) +{ + std::string result(p_name); + while (result.size() > 1) { + char c = result.back(); + if ((c >= '0' && c <= '9') || c == '_') { + result.pop_back(); + } + else { + break; + } + } + return result; +} + +const char* AnimUtils::ResolvePropLODName(const char* p_nodeName) +{ + static const struct { + const char* nodePrefix; + const char* lodName; + } mappings[] = { + {"popmug", "pizpie"}, + }; + + for (const auto& m : mappings) { + if (!SDL_strncasecmp(p_nodeName, m.nodePrefix, SDL_strlen(m.nodePrefix))) { + return m.lodName; + } + } + return p_nodeName; +} diff --git a/extensions/src/common/characteranimator.cpp b/extensions/src/common/characteranimator.cpp index 35da0c2d..c0c0b4b0 100644 --- a/extensions/src/common/characteranimator.cpp +++ b/extensions/src/common/characteranimator.cpp @@ -96,10 +96,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) float timeInCycle = m_animTime - duration * SDL_floorf(m_animTime / duration); MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = walkAnim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, walkRoiMap); - } + AnimUtils::ApplyTree(walkAnim, transform, (LegoTime) timeInCycle, walkRoiMap); } m_wasMoving = true; m_idleTime = 0.0f; @@ -139,15 +136,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) m_emotePropGroup.roiMap != nullptr ? m_emotePropGroup.roiMap : m_emoteAnimCache->roiMap; MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World()); - LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) m_emoteTime, - emoteRoiMap - ); - } + AnimUtils::ApplyTree(m_emoteAnimCache->anim, transform, (LegoTime) m_emoteTime, emoteRoiMap); // Restore player ROI transform (animation root overwrote it). if (m_config.saveEmoteTransform) { @@ -159,15 +148,12 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) // 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 - ); - } + AnimUtils::ApplyTree( + m_frozenAnimCache->anim, + transform, + (LegoTime) m_frozenAnimDuration, + m_frozenAnimCache->roiMap + ); if (m_config.saveEmoteTransform) { p_roi->WrappedSetLocal2WorldWithWorldDataUpdate(m_frozenParentTransform); @@ -193,15 +179,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) float timeInCycle = m_idleAnimTime - duration * SDL_floorf(m_idleAnimTime / duration); MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = m_idleAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) timeInCycle, - m_idleAnimCache->roiMap - ); - } + AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) timeInCycle, m_idleAnimCache->roiMap); } } } @@ -448,24 +426,6 @@ void CharacterAnimator::ClearPropGroup(PropGroup& p_group) p_group.anim = nullptr; } -// Maps animation tree node names to actual LOD names when they differ. -static const char* ResolvePropLODName(const char* p_nodeName) -{ - static const struct { - const char* nodePrefix; - const char* lodName; - } mappings[] = { - {"popmug", "pizpie"}, - }; - - for (const auto& m : mappings) { - if (!SDL_strncasecmp(p_nodeName, m.nodePrefix, SDL_strlen(m.nodePrefix))) { - return m.lodName; - } - } - return p_nodeName; -} - void CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, LegoROI* p_playerROI) { std::vector unmatchedNames; @@ -484,7 +444,7 @@ void CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, Le SDL_snprintf(uniqueName, sizeof(uniqueName), "tp_prop_%s", name.c_str()); } - const char* lodName = ResolvePropLODName(name.c_str()); + const char* lodName = AnimUtils::ResolvePropLODName(name.c_str()); LegoROI* propROI = CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE); if (propROI) { propROI->SetName(name.c_str()); @@ -545,8 +505,5 @@ void CharacterAnimator::ApplyIdleFrame0(LegoROI* p_roi) } MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = m_idleAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap); - } + AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap); } diff --git a/extensions/src/common/animdata.cpp b/extensions/src/common/charactertables.cpp similarity index 98% rename from extensions/src/common/animdata.cpp rename to extensions/src/common/charactertables.cpp index dab71b2f..cf048d7d 100644 --- a/extensions/src/common/animdata.cpp +++ b/extensions/src/common/charactertables.cpp @@ -1,4 +1,4 @@ -#include "extensions/common/animdata.h" +#include "extensions/common/charactertables.h" #include "legopathactor.h" diff --git a/extensions/src/common/pathutils.cpp b/extensions/src/common/pathutils.cpp new file mode 100644 index 00000000..de923713 --- /dev/null +++ b/extensions/src/common/pathutils.cpp @@ -0,0 +1,24 @@ +#include "extensions/common/pathutils.h" + +#include "legomain.h" + +#include + +using namespace Extensions::Common; + +bool Extensions::Common::ResolveGamePath(const char* p_relativePath, MxString& p_outPath) +{ + p_outPath = MxString(MxOmni::GetHD()) + p_relativePath; + p_outPath.MapPathToFilesystem(); + if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) { + return true; + } + + p_outPath = MxString(MxOmni::GetCD()) + p_relativePath; + p_outPath.MapPathToFilesystem(); + if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) { + return true; + } + + return false; +} diff --git a/extensions/src/multiplayer/animation/audioplayer.cpp b/extensions/src/multiplayer/animation/audioplayer.cpp new file mode 100644 index 00000000..95dc88af --- /dev/null +++ b/extensions/src/multiplayer/animation/audioplayer.cpp @@ -0,0 +1,50 @@ +#include "extensions/multiplayer/animation/audioplayer.h" + +#include "extensions/multiplayer/animation/loader.h" +#include "legocachsound.h" + +using namespace Multiplayer::Animation; + +void AudioPlayer::Init(const std::vector& p_tracks) +{ + for (const auto& audioTrack : p_tracks) { + LegoCacheSound* sound = new LegoCacheSound(); + MxString mediaSrcPath(audioTrack.mediaSrcPath.c_str()); + MxWavePresenter::WaveFormat format = audioTrack.format; + if (sound->Create(format, mediaSrcPath, audioTrack.volume, audioTrack.pcmData, audioTrack.pcmDataSize) == + SUCCESS) { + ActiveSound active; + active.sound = sound; + active.timeOffset = audioTrack.timeOffset; + active.started = false; + m_activeSounds.push_back(active); + } + else { + delete sound; + } + } +} + +void AudioPlayer::Tick(float p_elapsedMs, const char* p_roiName) +{ + for (auto& active : m_activeSounds) { + if (!active.started && p_elapsedMs >= (float) active.timeOffset) { + active.sound->Play(p_roiName, FALSE); + active.started = true; + } + if (active.started) { + active.sound->FUN_10006be0(); + } + } +} + +void AudioPlayer::Cleanup() +{ + for (auto& active : m_activeSounds) { + if (active.started) { + active.sound->Stop(); + } + delete active.sound; + } + m_activeSounds.clear(); +} diff --git a/extensions/src/multiplayer/animation/catalog.cpp b/extensions/src/multiplayer/animation/catalog.cpp new file mode 100644 index 00000000..20ec04cf --- /dev/null +++ b/extensions/src/multiplayer/animation/catalog.cpp @@ -0,0 +1,228 @@ +#include "extensions/multiplayer/animation/catalog.h" + +#include "decomp.h" +#include "legoanimationmanager.h" +#include "legocharactermanager.h" +#include "misc.h" + +#include + +using namespace Multiplayer::Animation; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +// Exact-match a model name against g_characters[].m_name. +// The engine's LegoAnimationManager::GetCharacterIndex uses 2-char prefix matching, +// which causes false positives (e.g. "ladder" matching "laura"). We need exact +// matching to correctly identify character performers vs props. +static int8_t GetCharacterIndex(const char* p_name) +{ + for (int8_t i = 0; i < (int8_t) sizeOfArray(g_characters); i++) { + if (!SDL_strcasecmp(p_name, g_characters[i].m_name)) { + return i; + } + } + return -1; +} + +std::vector Multiplayer::Animation::GetPerformerIndices(uint64_t p_performerMask) +{ + std::vector indices; + for (int8_t i = 0; i < 64; i++) { + if (p_performerMask & (uint64_t(1) << i)) { + indices.push_back(i); + } + } + return indices; +} + +void Catalog::Refresh(LegoAnimationManager* p_am) +{ + m_entries.clear(); + m_locationIndex.clear(); + m_animsBase = nullptr; + m_animCount = 0; + + if (!p_am) { + return; + } + + m_animCount = p_am->m_animCount; + m_animsBase = p_am->m_anims; + + if (!m_animsBase || m_animCount == 0) { + return; + } + + for (uint16_t i = 0; i < m_animCount; i++) { + if (!m_animsBase[i].m_name || m_animsBase[i].m_objectId == 0) { + continue; + } + + CatalogEntry entry; + entry.animIndex = i; + entry.spectatorMask = m_animsBase[i].m_unk0x0c; + entry.location = m_animsBase[i].m_location; + entry.characterIndex = m_animsBase[i].m_characterIndex; + entry.modelCount = m_animsBase[i].m_modelCount; + + if (entry.characterIndex < 0) { + entry.category = e_otherAnim; + } + else if (entry.location == -1) { + entry.category = e_npcAnim; + } + else { + entry.category = e_camAnim; + } + + // Compute performerMask by matching models against g_characters[].m_name + entry.performerMask = 0; + for (uint8_t m = 0; m < entry.modelCount; m++) { + if (m_animsBase[i].m_models && m_animsBase[i].m_models[m].m_name) { + int8_t charIdx = GetCharacterIndex(m_animsBase[i].m_models[m].m_name); + if (charIdx >= 0) { + entry.performerMask |= (uint64_t(1) << charIdx); + } + } + } + + size_t idx = m_entries.size(); + m_entries.push_back(entry); + + // Build location index + m_locationIndex[entry.location].push_back(idx); + } + +} + +const AnimInfo* Catalog::GetAnimInfo(uint16_t p_animIndex) const +{ + if (!m_animsBase || p_animIndex >= m_animCount) { + return nullptr; + } + return &m_animsBase[p_animIndex]; +} + +int8_t Catalog::DisplayActorToCharacterIndex(uint8_t p_displayActorIndex) +{ + const char* actorName = CharacterManager()->GetActorName(p_displayActorIndex); + if (!actorName) { + return -1; + } + + return GetCharacterIndex(actorName); +} + +const CatalogEntry* Catalog::FindEntry(uint16_t p_animIndex) const +{ + for (const auto& entry : m_entries) { + if (entry.animIndex == p_animIndex) { + return &entry; + } + } + return nullptr; +} + +std::vector Catalog::GetAnimationsAtLocation(int16_t p_location) const +{ + std::vector result; + + // Helper to add entries from a location, filtering out e_otherAnim + auto addFromLocation = [&](int16_t loc) { + auto it = m_locationIndex.find(loc); + if (it != m_locationIndex.end()) { + for (size_t idx : it->second) { + if (m_entries[idx].category != e_otherAnim) { + result.push_back(&m_entries[idx]); + } + } + } + }; + + // Always include NPC animations (location == -1) + addFromLocation(-1); + + // If requesting a specific location, also include location-bound animations + if (p_location >= 0) { + addFromLocation(p_location); + } + + return result; +} + +bool Catalog::CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex) +{ + if (p_charIndex < CORE_CHARACTER_COUNT) { + return (p_entry->spectatorMask >> p_charIndex) & 1; + } + + // Non-core characters (index 5+): only if all core actors allowed + return p_entry->spectatorMask == ALL_CORE_ACTORS_MASK; +} + +bool Catalog::CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex) +{ + if (p_charIndex < 0) { + return false; + } + + // Performer: player's character is one of the performing models + if ((p_entry->performerMask >> p_charIndex) & 1) { + return true; + } + + // Spectator: not a performer, spectator mask allows them + return CheckSpectatorMask(p_entry, p_charIndex); +} + +bool Catalog::CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActorIndex) const +{ + return CanParticipateChar(p_entry, DisplayActorToCharacterIndex(p_displayActorIndex)); +} + +bool Catalog::CanTrigger( + const CatalogEntry* p_entry, + const int8_t* p_charIndices, + uint8_t p_count, + uint64_t* p_filledPerformers, + bool* p_spectatorFilled +) const +{ + *p_filledPerformers = 0; + *p_spectatorFilled = false; + + // First pass: assign performers (each performer slot needs exactly one player) + std::vector assignedAsPerformer(p_count, false); + + for (uint8_t i = 0; i < p_count; i++) { + int8_t charIndex = p_charIndices[i]; + if (charIndex < 0) { + continue; + } + + uint64_t charBit = uint64_t(1) << charIndex; + if ((p_entry->performerMask & charBit) && !(*p_filledPerformers & charBit)) { + *p_filledPerformers |= charBit; + assignedAsPerformer[i] = true; + } + } + + bool allPerformersCovered = (*p_filledPerformers == p_entry->performerMask); + + // Second pass: find a spectator among unassigned players + for (uint8_t i = 0; i < p_count; i++) { + if (assignedAsPerformer[i]) { + continue; + } + + int8_t charIndex = p_charIndices[i]; + if (charIndex >= 0 && !((p_entry->performerMask >> charIndex) & 1) && CheckSpectatorMask(p_entry, charIndex)) { + *p_spectatorFilled = true; + break; + } + } + + return allPerformersCovered && *p_spectatorFilled; +} diff --git a/extensions/src/multiplayer/animation/coordinator.cpp b/extensions/src/multiplayer/animation/coordinator.cpp new file mode 100644 index 00000000..2bbe34fc --- /dev/null +++ b/extensions/src/multiplayer/animation/coordinator.cpp @@ -0,0 +1,271 @@ +#include "extensions/multiplayer/animation/coordinator.h" + +#include "extensions/multiplayer/animation/catalog.h" +#include "legoanimationmanager.h" + +#include + +using namespace Multiplayer::Animation; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +Coordinator::Coordinator() + : m_catalog(nullptr), m_state(CoordinationState::e_idle), m_currentAnimIndex(ANIM_INDEX_NONE), m_localPeerId(0), + m_cancelPending(false) +{ +} + +void Coordinator::SetCatalog(const Catalog* p_catalog) +{ + m_catalog = p_catalog; +} + +void Coordinator::SetLocalPeerId(uint32_t p_peerId) +{ + m_localPeerId = p_peerId; +} + +void Coordinator::SetInterest(uint16_t p_animIndex) +{ + if (m_state != CoordinationState::e_idle && m_state != CoordinationState::e_interested) { + return; + } + + m_currentAnimIndex = p_animIndex; + m_state = CoordinationState::e_interested; + m_cancelPending = false; +} + +void Coordinator::ClearInterest() +{ + if (m_state == CoordinationState::e_interested || m_state == CoordinationState::e_countdown || + m_state == CoordinationState::e_playing) { + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + m_cancelPending = true; + } +} + +// Build the unified slots vector from CanTrigger results. +// Each bit in performerMask becomes one slot; the spectator becomes one slot at the end. +static void BuildSlots( + const CatalogEntry* p_entry, + uint64_t p_filledPerformers, + bool p_spectatorFilled, + std::vector& p_slots +) +{ + // One slot per performer bit in performerMask + for (int8_t i : GetPerformerIndices(p_entry->performerMask)) { + SlotInfo slot; + if (i < (int8_t) sizeOfArray(g_characters)) { + slot.names.push_back(g_characters[i].m_name); + } + slot.filled = (p_filledPerformers & (uint64_t(1) << i)) != 0; + p_slots.push_back(std::move(slot)); + } + + // One spectator slot + SlotInfo spectatorSlot; + if (p_entry->spectatorMask == ALL_CORE_ACTORS_MASK) { + spectatorSlot.names.push_back("any"); + } + else { + for (int8_t i = 0; i < CORE_CHARACTER_COUNT; i++) { + if ((p_entry->spectatorMask >> i) & 1) { + spectatorSlot.names.push_back(g_characters[i].m_name); + } + } + } + spectatorSlot.filled = p_spectatorFilled; + p_slots.push_back(std::move(spectatorSlot)); +} + +std::vector Coordinator::ComputeEligibility( + int16_t p_location, + const int8_t* p_locationChars, + uint8_t p_locationCount, + const int8_t* p_proximityChars, + uint8_t p_proximityCount +) const +{ + std::vector result; + + if (!m_catalog || p_locationCount == 0) { + return result; + } + + auto anims = m_catalog->GetAnimationsAtLocation(p_location); + + for (const CatalogEntry* entry : anims) { + // p_locationChars[0] == p_proximityChars[0] == local player + if (!Catalog::CanParticipateChar(entry, p_locationChars[0])) { + continue; + } + + // NPC anims (location == -1): use proximity characters + // Cam anims (location >= 0): use location characters + const int8_t* chars = (entry->location == -1) ? p_proximityChars : p_locationChars; + uint8_t count = (entry->location == -1) ? p_proximityCount : p_locationCount; + + EligibilityInfo info; + info.animIndex = entry->animIndex; + info.entry = entry; + + bool atLoc = (entry->location == -1) || (entry->location == p_location); + info.atLocation = atLoc; + + uint64_t filledPerformers = 0; + bool spectatorFilled = false; + + if (atLoc) { + info.eligible = m_catalog->CanTrigger(entry, chars, count, &filledPerformers, &spectatorFilled); + } + else { + info.eligible = false; + } + + BuildSlots(entry, filledPerformers, spectatorFilled, info.slots); + + // Override slot fills with authoritative session data + auto sessionIt = m_sessions.find(entry->animIndex); + if (sessionIt != m_sessions.end()) { + const SessionView& sv = sessionIt->second; + uint8_t slotCount = + sv.slotCount < info.slots.size() ? sv.slotCount : static_cast(info.slots.size()); + for (uint8_t s = 0; s < slotCount; s++) { + info.slots[s].filled = (sv.peerSlots[s] != 0); + } + } + + result.push_back(std::move(info)); + } + + return result; +} + +void Coordinator::OnLocationChanged(int16_t p_location, const Catalog* p_catalog) +{ + if (m_state != CoordinationState::e_interested || !p_catalog) { + return; + } + + auto anims = p_catalog->GetAnimationsAtLocation(p_location); + for (const auto* e : anims) { + if (e->animIndex == m_currentAnimIndex) { + return; // still available + } + } + + // Animation not at new location — clear interest + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + m_cancelPending = true; +} + +void Coordinator::Reset() +{ + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + m_sessions.clear(); + m_cancelPending = false; +} + +void Coordinator::ApplySessionUpdate( + uint16_t p_animIndex, + uint8_t p_state, + uint16_t p_countdownMs, + const uint32_t p_slots[8], + uint8_t p_slotCount +) +{ + if (p_state == 0) { + // Session cleared + m_sessions.erase(p_animIndex); + + // If local player was in this session, reset to idle + if (m_currentAnimIndex == p_animIndex && + (m_state == CoordinationState::e_interested || m_state == CoordinationState::e_countdown || + m_state == CoordinationState::e_playing)) { + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + } + return; + } + + SessionView& sv = m_sessions[p_animIndex]; + sv.state = static_cast(p_state); + sv.countdownMs = p_countdownMs; + sv.countdownEndTime = (p_countdownMs > 0) ? (SDL_GetTicks() + p_countdownMs) : 0; + sv.slotCount = p_slotCount < 8 ? p_slotCount : 8; + for (uint8_t i = 0; i < 8; i++) { + sv.peerSlots[i] = (i < sv.slotCount) ? p_slots[i] : 0; + } + + // If local player is in this session, update coordinator state + if (m_localPeerId != 0) { + bool localInSession = false; + for (uint8_t i = 0; i < sv.slotCount; i++) { + if (sv.peerSlots[i] == m_localPeerId) { + localInSession = true; + break; + } + } + + if (localInSession && !m_cancelPending) { + m_currentAnimIndex = p_animIndex; + m_state = sv.state; + } + else if (!localInSession) { + if (m_currentAnimIndex == p_animIndex) { + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + } + m_cancelPending = false; + } + } +} + +void Coordinator::ApplyAnimStart(uint16_t p_animIndex) +{ + if (IsLocalPlayerInSession(p_animIndex)) { + m_state = CoordinationState::e_playing; + m_currentAnimIndex = p_animIndex; + } + + // Update session view so PushAnimationState reads correct values + auto it = m_sessions.find(p_animIndex); + if (it != m_sessions.end()) { + it->second.state = CoordinationState::e_playing; + it->second.countdownMs = 0; + } +} + +const SessionView* Coordinator::GetSessionView(uint16_t p_animIndex) const +{ + auto it = m_sessions.find(p_animIndex); + if (it != m_sessions.end()) { + return &it->second; + } + return nullptr; +} + +bool Coordinator::IsLocalPlayerInSession(uint16_t p_animIndex) const +{ + if (m_cancelPending || m_localPeerId == 0) { + return false; + } + + auto it = m_sessions.find(p_animIndex); + if (it == m_sessions.end()) { + return false; + } + + for (uint8_t i = 0; i < it->second.slotCount; i++) { + if (it->second.peerSlots[i] == m_localPeerId) { + return true; + } + } + return false; +} diff --git a/extensions/src/multiplayer/animation/loader.cpp b/extensions/src/multiplayer/animation/loader.cpp new file mode 100644 index 00000000..55ed4400 --- /dev/null +++ b/extensions/src/multiplayer/animation/loader.cpp @@ -0,0 +1,442 @@ +#include "extensions/multiplayer/animation/loader.h" + +#include "anim/legoanim.h" +#include "extensions/common/pathutils.h" +#include "flic.h" +#include "misc/legostorage.h" +#include "mxautolock.h" +#include "mxwavepresenter.h" + +#include +#include +#include + +using namespace Multiplayer::Animation; + +static void ParseExtraDirectives(const si::bytearray& p_extra, SceneAnimData& p_data) +{ + if (p_extra.empty()) { + return; + } + + std::string extra(p_extra.data(), p_extra.size()); + while (!extra.empty() && extra.back() == '\0') { + extra.pop_back(); + } + + if (extra.find("HIDE_ON_STOP") != std::string::npos) { + p_data.hideOnStop = true; + } + + size_t pos = extra.find("PTATCAM="); + if (pos != std::string::npos) { + pos += 8; + size_t end = extra.find(' ', pos); + std::string value = (end != std::string::npos) ? extra.substr(pos, end - pos) : extra.substr(pos); + + size_t start = 0; + while (start < value.size()) { + size_t delim = value.find_first_of(":;", start); + std::string token = (delim != std::string::npos) ? value.substr(start, delim - start) : value.substr(start); + + if (!token.empty()) { + p_data.ptAtCamNames.push_back(token); + } + + start = (delim != std::string::npos) ? delim + 1 : value.size(); + } + } +} + +SceneAnimData::SceneAnimData() : anim(nullptr), duration(0.0f), actionTransform{}, hideOnStop(false) +{ +} + +SceneAnimData::~SceneAnimData() +{ + delete anim; + ReleaseTracks(); +} + +void SceneAnimData::ReleaseTracks() +{ + for (auto& track : audioTracks) { + delete[] track.pcmData; + } + + for (auto& track : phonemeTracks) { + delete[] reinterpret_cast(track.flcHeader); + } +} + +SceneAnimData::SceneAnimData(SceneAnimData&& p_other) noexcept + : anim(p_other.anim), duration(p_other.duration), audioTracks(std::move(p_other.audioTracks)), + phonemeTracks(std::move(p_other.phonemeTracks)), actionTransform(p_other.actionTransform), + ptAtCamNames(std::move(p_other.ptAtCamNames)), hideOnStop(p_other.hideOnStop) +{ + p_other.anim = nullptr; +} + +SceneAnimData& SceneAnimData::operator=(SceneAnimData&& p_other) noexcept +{ + if (this != &p_other) { + delete anim; + ReleaseTracks(); + + anim = p_other.anim; + duration = p_other.duration; + audioTracks = std::move(p_other.audioTracks); + phonemeTracks = std::move(p_other.phonemeTracks); + actionTransform = p_other.actionTransform; + ptAtCamNames = std::move(p_other.ptAtCamNames); + hideOnStop = p_other.hideOnStop; + p_other.anim = nullptr; + } + return *this; +} + +Loader::Loader() + : m_siFile(nullptr), m_interleaf(nullptr), m_siReady(false), m_preloadThread(nullptr), m_preloadObjectId(0), + m_preloadDone(false) +{ +} + +Loader::~Loader() +{ + CleanupPreloadThread(); + delete m_interleaf; + delete m_siFile; +} + +bool Loader::OpenSIHeaderOnly(const char* p_siPath, si::File*& p_file, si::Interleaf*& p_interleaf) +{ + p_file = new si::File(); + + MxString path; + if (!Extensions::Common::ResolveGamePath(p_siPath, path) || !p_file->Open(path.GetData(), si::File::Read)) { + delete p_file; + p_file = nullptr; + return false; + } + + p_interleaf = new si::Interleaf(); + if (p_interleaf->Read(p_file, si::Interleaf::HeaderOnly) != si::Interleaf::ERROR_SUCCESS) { + delete p_interleaf; + p_interleaf = nullptr; + p_file->Close(); + delete p_file; + p_file = nullptr; + return false; + } + + return true; +} + +bool Loader::OpenSI() +{ + if (m_siReady) { + return true; + } + + if (!OpenSIHeaderOnly("\\lego\\scripts\\isle\\isle.si", m_siFile, m_interleaf)) { + return false; + } + + m_siReady = true; + return true; +} + +bool Loader::ReadObject(uint32_t p_objectId) +{ + if (!m_siReady) { + return false; + } + + size_t childCount = m_interleaf->GetChildCount(); + if (p_objectId >= childCount) { + return false; + } + + si::Object* obj = static_cast(m_interleaf->GetChildAt(p_objectId)); + if (obj->type() != si::MxOb::Null) { + return true; + } + + return m_interleaf->ReadObject(m_siFile, p_objectId) == si::Interleaf::ERROR_SUCCESS; +} + +bool Loader::ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data) +{ + auto& chunks = p_child->data_; + if (chunks.empty()) { + return false; + } + + auto& firstChunk = chunks[0]; + if (firstChunk.size() < 7 * sizeof(MxS32)) { + return false; + } + + LegoMemory storage(firstChunk.data(), (LegoU32) firstChunk.size()); + + MxS32 magicSig; + if (storage.Read(&magicSig, sizeof(MxS32)) != SUCCESS || magicSig != 0x11) { + return false; + } + + // Skip boundingRadius + centerPoint[3] (unused, but present in the binary format) + LegoU32 pos; + storage.GetPosition(pos); + storage.SetPosition(pos + 4 * sizeof(float)); + + LegoS32 parseScene = 0; + MxS32 val3; + if (storage.Read(&parseScene, sizeof(LegoS32)) != SUCCESS) { + return false; + } + if (storage.Read(&val3, sizeof(MxS32)) != SUCCESS) { + return false; + } + + p_data.anim = new LegoAnim(); + if (p_data.anim->Read(&storage, parseScene) != SUCCESS) { + delete p_data.anim; + p_data.anim = nullptr; + return false; + } + + p_data.duration = (float) p_data.anim->GetDuration(); + return true; +} + +bool Loader::ParseSoundChild(si::Object* p_child, SceneAnimData& p_data) +{ + auto& chunks = p_child->data_; + if (chunks.size() < 2) { + return false; + } + + // data_[0] = WaveFormat header, data_[1..N] = raw PCM blocks + const auto& header = chunks[0]; + if (header.size() < sizeof(MxWavePresenter::WaveFormat)) { + return false; + } + + SceneAnimData::AudioTrack track; + SDL_memcpy(&track.format, header.data(), sizeof(MxWavePresenter::WaveFormat)); + track.pcmData = nullptr; + track.pcmDataSize = 0; + track.volume = (int32_t) p_child->volume_; + track.timeOffset = p_child->time_offset_; + track.mediaSrcPath = p_child->filename_; + + MxU32 totalPcm = 0; + for (size_t i = 1; i < chunks.size(); i++) { + totalPcm += (MxU32) chunks[i].size(); + } + + if (totalPcm == 0) { + return false; + } + + track.pcmData = new MxU8[totalPcm]; + track.pcmDataSize = totalPcm; + track.format.m_dataSize = totalPcm; + MxU32 offset = 0; + for (size_t i = 1; i < chunks.size(); i++) { + SDL_memcpy(track.pcmData + offset, chunks[i].data(), chunks[i].size()); + offset += (MxU32) chunks[i].size(); + } + + p_data.audioTracks.push_back(std::move(track)); + return true; +} + +bool Loader::ParsePhonemeChild(si::Object* p_child, SceneAnimData& p_data) +{ + auto& chunks = p_child->data_; + if (chunks.size() < 2) { + return false; + } + + SceneAnimData::PhonemeTrack track; + + const auto& headerChunk = chunks[0]; + if (headerChunk.size() < sizeof(FLIC_HEADER)) { + return false; + } + + MxU8* headerBuf = new MxU8[headerChunk.size()]; + SDL_memcpy(headerBuf, headerChunk.data(), headerChunk.size()); + track.flcHeader = reinterpret_cast(headerBuf); + track.width = track.flcHeader->width; + track.height = track.flcHeader->height; + + for (size_t i = 1; i < chunks.size(); i++) { + track.frameData.push_back(chunks[i]); + } + + if (!p_child->extra_.empty()) { + track.roiName = std::string(p_child->extra_.data(), p_child->extra_.size()); + while (!track.roiName.empty() && track.roiName.back() == '\0') { + track.roiName.pop_back(); + } + } + + track.timeOffset = p_child->time_offset_; + + p_data.phonemeTracks.push_back(std::move(track)); + return true; +} + +bool Loader::ParseComposite(si::Object* p_composite, SceneAnimData& p_data) +{ + bool hasAnim = false; + + for (size_t i = 0; i < p_composite->GetChildCount(); i++) { + si::Object* child = static_cast(p_composite->GetChildAt(i)); + + if (child->presenter_.find("LegoPhonemePresenter") != std::string::npos) { + ParsePhonemeChild(child, p_data); + } + else if (child->presenter_.find("LegoAnimPresenter") != std::string::npos || child->presenter_.find("LegoLoopingAnimPresenter") != std::string::npos) { + if (!hasAnim) { + if (ParseAnimationChild(child, p_data)) { + hasAnim = true; + ParseExtraDirectives(child->extra_, p_data); + + // Extract action transform. Try child first, fall back to composite if zero. + si::Object* source = child; + if (SDL_fabs(child->direction_.x) < 1e-7 && SDL_fabs(child->direction_.y) < 1e-7 && + SDL_fabs(child->direction_.z) < 1e-7) { + source = p_composite; + } + + p_data.actionTransform.location[0] = (float) source->location_.x; + p_data.actionTransform.location[1] = (float) source->location_.y; + p_data.actionTransform.location[2] = (float) source->location_.z; + p_data.actionTransform.direction[0] = (float) source->direction_.x; + p_data.actionTransform.direction[1] = (float) source->direction_.y; + p_data.actionTransform.direction[2] = (float) source->direction_.z; + p_data.actionTransform.up[0] = (float) source->up_.x; + p_data.actionTransform.up[1] = (float) source->up_.y; + p_data.actionTransform.up[2] = (float) source->up_.z; + + p_data.actionTransform.valid = + (SDL_fabsf(p_data.actionTransform.direction[0]) >= 0.00000047683716f || + SDL_fabsf(p_data.actionTransform.direction[1]) >= 0.00000047683716f || + SDL_fabsf(p_data.actionTransform.direction[2]) >= 0.00000047683716f); + } + } + } + else if (child->filetype() == si::MxOb::WAV) { + ParseSoundChild(child, p_data); + } + } + + return hasAnim; +} + +SceneAnimData* Loader::EnsureCached(uint32_t p_objectId) +{ + { + AUTOLOCK(m_cacheCS); + auto it = m_cache.find(p_objectId); + if (it != m_cache.end()) { + return &it->second; + } + } + + // If a preload is in progress for this object, wait for it to finish + if (m_preloadThread && m_preloadObjectId == p_objectId) { + CleanupPreloadThread(); + + AUTOLOCK(m_cacheCS); + auto it = m_cache.find(p_objectId); + if (it != m_cache.end()) { + return &it->second; + } + // Preload failed — fall through to synchronous load + } + + if (!OpenSI()) { + return nullptr; + } + + if (!ReadObject(p_objectId)) { + return nullptr; + } + + si::Object* composite = static_cast(m_interleaf->GetChildAt(p_objectId)); + + SceneAnimData data; + if (!ParseComposite(composite, data)) { + return nullptr; + } + + AUTOLOCK(m_cacheCS); + auto result = m_cache.emplace(p_objectId, std::move(data)); + return &result.first->second; +} + +void Loader::CleanupPreloadThread() +{ + if (m_preloadThread) { + delete m_preloadThread; + m_preloadThread = nullptr; + } +} + +void Loader::PreloadAsync(uint32_t p_objectId) +{ + { + AUTOLOCK(m_cacheCS); + if (m_cache.find(p_objectId) != m_cache.end()) { + return; + } + } + + if (m_preloadThread && m_preloadObjectId == p_objectId && !m_preloadDone) { + return; + } + + CleanupPreloadThread(); + + m_preloadObjectId = p_objectId; + m_preloadDone = false; + m_preloadThread = new PreloadThread(this, p_objectId); + m_preloadThread->Start(0x1000, 0); +} + +Loader::PreloadThread::PreloadThread(Loader* p_loader, uint32_t p_objectId) : m_loader(p_loader), m_objectId(p_objectId) +{ +} + +MxResult Loader::PreloadThread::Run() +{ + si::File* siFile = nullptr; + si::Interleaf* interleaf = nullptr; + + if (!OpenSIHeaderOnly("\\lego\\scripts\\isle\\isle.si", siFile, interleaf)) { + m_loader->m_preloadDone = true; + return MxThread::Run(); + } + + size_t childCount = interleaf->GetChildCount(); + if (m_objectId < childCount && interleaf->ReadObject(siFile, m_objectId) == si::Interleaf::ERROR_SUCCESS) { + si::Object* composite = static_cast(interleaf->GetChildAt(m_objectId)); + + SceneAnimData data; + if (ParseComposite(composite, data)) { + AUTOLOCK(m_loader->m_cacheCS); + m_loader->m_cache.emplace(m_objectId, std::move(data)); + } + } + + m_loader->m_preloadDone = true; + + delete interleaf; + delete siFile; + + return MxThread::Run(); +} diff --git a/extensions/src/multiplayer/animation/locationproximity.cpp b/extensions/src/multiplayer/animation/locationproximity.cpp new file mode 100644 index 00000000..b5e126bd --- /dev/null +++ b/extensions/src/multiplayer/animation/locationproximity.cpp @@ -0,0 +1,60 @@ +#include "extensions/multiplayer/animation/locationproximity.h" + +#include "decomp.h" +#include "legolocations.h" + +#include + +using namespace Multiplayer::Animation; + +static const float DEFAULT_RADIUS = NPC_ANIM_PROXIMITY; + +// Location 0 is the camera origin, and the last location is overhead — skip both +static const int FIRST_VALID_LOCATION = 1; +static const int LAST_VALID_LOCATION = sizeOfArray(g_locations) - 2; + +LocationProximity::LocationProximity() : m_nearestLocation(-1), m_nearestDistance(0.0f), m_radius(DEFAULT_RADIUS) +{ +} + +bool LocationProximity::Update(float p_x, float p_z) +{ + int16_t prev = m_nearestLocation; + m_nearestLocation = ComputeNearest(p_x, p_z, m_radius); + + if (m_nearestLocation >= 0) { + float dx = p_x - g_locations[m_nearestLocation].m_position[0]; + float dz = p_z - g_locations[m_nearestLocation].m_position[2]; + m_nearestDistance = std::sqrt(dx * dx + dz * dz); + } + else { + m_nearestDistance = 0.0f; + } + + return m_nearestLocation != prev; +} + +void LocationProximity::Reset() +{ + m_nearestLocation = -1; + m_nearestDistance = 0.0f; +} + +int16_t LocationProximity::ComputeNearest(float p_x, float p_z, float p_radius) +{ + float bestDist = p_radius; + int16_t bestLocation = -1; + + for (int i = FIRST_VALID_LOCATION; i <= LAST_VALID_LOCATION; i++) { + float dx = p_x - g_locations[i].m_position[0]; + float dz = p_z - g_locations[i].m_position[2]; + float dist = std::sqrt(dx * dx + dz * dz); + + if (dist < bestDist) { + bestDist = dist; + bestLocation = static_cast(i); + } + } + + return bestLocation; +} diff --git a/extensions/src/multiplayer/animation/phonemeplayer.cpp b/extensions/src/multiplayer/animation/phonemeplayer.cpp new file mode 100644 index 00000000..c9542fa2 --- /dev/null +++ b/extensions/src/multiplayer/animation/phonemeplayer.cpp @@ -0,0 +1,169 @@ +#include "extensions/multiplayer/animation/phonemeplayer.h" + +#include "extensions/multiplayer/animation/loader.h" +#include "flic.h" +#include "legocharactermanager.h" +#include "misc.h" +#include "misc/legocontainer.h" +#include "mxbitmap.h" +#include "roi/legoroi.h" + +#include + +using namespace Multiplayer::Animation; + +// Find the ROI matching a phoneme track's roiName in the roiMap. +static LegoROI* FindTrackROI(const std::string& p_roiName, LegoROI** p_roiMap, MxU32 p_roiMapSize) +{ + if (p_roiName.empty() || !p_roiMap) { + return nullptr; + } + + for (MxU32 i = 1; i < p_roiMapSize; i++) { + if (p_roiMap[i] && p_roiMap[i]->GetName() && !SDL_strcasecmp(p_roiName.c_str(), p_roiMap[i]->GetName())) { + return p_roiMap[i]; + } + } + return nullptr; +} + +void PhonemePlayer::Init(const std::vector& p_tracks, LegoROI** p_roiMap, MxU32 p_roiMapSize) +{ + for (auto& track : p_tracks) { + PhonemeState state; + state.targetROI = nullptr; + state.originalTexture = nullptr; + state.cachedTexture = nullptr; + state.bitmap = nullptr; + state.currentFrame = -1; + + // Resolve the target ROI from the track's roiName via the roiMap + LegoROI* targetROI = FindTrackROI(track.roiName, p_roiMap, p_roiMapSize); + if (!targetROI) { + m_states.push_back(state); + continue; + } + state.targetROI = targetROI; + + LegoROI* head = targetROI->FindChildROI("head", targetROI); + if (!head) { + m_states.push_back(state); + continue; + } + + LegoTextureInfo* originalInfo = nullptr; + head->GetTextureInfo(originalInfo); + if (!originalInfo) { + m_states.push_back(state); + continue; + } + state.originalTexture = originalInfo; + + LegoTextureInfo* cached = TextureContainer()->GetCached(originalInfo); + if (!cached) { + m_states.push_back(state); + continue; + } + state.cachedTexture = cached; + + CharacterManager()->SetHeadTexture(targetROI, cached); + + state.bitmap = new MxBitmap(); + state.bitmap->SetSize(track.width, track.height, NULL, FALSE); + + m_states.push_back(state); + } +} + +void PhonemePlayer::Tick(float p_elapsedMs, const std::vector& p_tracks) +{ + for (size_t i = 0; i < p_tracks.size() && i < m_states.size(); i++) { + auto& track = p_tracks[i]; + auto& state = m_states[i]; + + if (!state.bitmap || !state.cachedTexture) { + continue; + } + + float trackElapsed = p_elapsedMs - (float) track.timeOffset; + if (trackElapsed < 0.0f) { + continue; + } + + if (track.flcHeader->speed == 0) { + continue; + } + + int targetFrame = (int) (trackElapsed / (float) track.flcHeader->speed); + if (targetFrame == state.currentFrame) { + continue; + } + if (targetFrame >= (int) track.frameData.size()) { + continue; + } + + int startFrame = state.currentFrame + 1; + if (startFrame < 0) { + startFrame = 0; + } + + for (int f = startFrame; f <= targetFrame; f++) { + const auto& data = track.frameData[f]; + if (data.size() < sizeof(MxS32)) { + continue; + } + + MxS32 rectCount; + SDL_memcpy(&rectCount, data.data(), sizeof(MxS32)); + size_t headerSize = sizeof(MxS32) + rectCount * sizeof(MxRect32); + if (data.size() <= headerSize) { + continue; + } + + FLIC_FRAME* flcFrame = (FLIC_FRAME*) (data.data() + headerSize); + + BYTE decodedColorMap; + DecodeFLCFrame( + &state.bitmap->GetBitmapInfo()->m_bmiHeader, + state.bitmap->GetImage(), + track.flcHeader, + flcFrame, + &decodedColorMap + ); + + // When the FLC frame updates the palette, apply it to the texture surface + if (decodedColorMap && state.cachedTexture->m_palette) { + PALETTEENTRY entries[256]; + RGBQUAD* colors = state.bitmap->GetBitmapInfo()->m_bmiColors; + for (int c = 0; c < 256; c++) { + entries[c].peRed = colors[c].rgbRed; + entries[c].peGreen = colors[c].rgbGreen; + entries[c].peBlue = colors[c].rgbBlue; + entries[c].peFlags = PC_NONE; + } + state.cachedTexture->m_palette->SetEntries(0, 0, 256, entries); + } + } + + state.cachedTexture->LoadBits(state.bitmap->GetImage()); + state.currentFrame = targetFrame; + } +} + +void PhonemePlayer::Cleanup() +{ + for (size_t i = 0; i < m_states.size(); i++) { + auto& state = m_states[i]; + + if (state.targetROI && state.originalTexture) { + CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture); + } + + if (state.cachedTexture) { + TextureContainer()->EraseCached(state.cachedTexture); + } + + delete state.bitmap; + } + m_states.clear(); +} diff --git a/extensions/src/multiplayer/animation/sceneplayer.cpp b/extensions/src/multiplayer/animation/sceneplayer.cpp new file mode 100644 index 00000000..74e6dc23 --- /dev/null +++ b/extensions/src/multiplayer/animation/sceneplayer.cpp @@ -0,0 +1,575 @@ +#include "extensions/multiplayer/animation/sceneplayer.h" + +#include "3dmanager/lego3dmanager.h" +#include "anim/legoanim.h" +#include "extensions/common/animutils.h" +#include "extensions/common/charactercloner.h" +#include "legoanimationmanager.h" +#include "legocameracontroller.h" +#include "legocharactermanager.h" +#include "legovideomanager.h" +#include "legoworld.h" +#include "misc.h" +#include "misc/legotree.h" +#include "mxgeometry/mxgeometry3d.h" +#include "realtime/realtime.h" +#include "roi/legoroi.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace Multiplayer::Animation; +namespace AnimUtils = Extensions::Common::AnimUtils; +using Extensions::Common::CharacterCloner; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +enum VehicleCategory { + e_bike, + e_motorcycle, + e_skateboard, + e_unknownVehicle +}; + +static VehicleCategory GetVehicleCategory(MxU32 p_vehicleIdx) +{ + if (p_vehicleIdx <= 3) { + return e_bike; + } + if (p_vehicleIdx <= 5) { + return e_motorcycle; + } + if (p_vehicleIdx == 6) { + return e_skateboard; + } + return e_unknownVehicle; +} + +static bool MatchesCharacter(const std::string& p_actorName, int8_t p_charIndex) +{ + if (p_charIndex < 0 || p_charIndex >= (int8_t) sizeOfArray(g_characters)) { + return false; + } + return !SDL_strcasecmp(p_actorName.c_str(), g_characters[p_charIndex].m_name); +} + +ScenePlayer::ScenePlayer() + : m_playing(false), m_rebaseComputed(false), m_startTime(0), m_currentData(nullptr), m_category(e_npcAnim), + m_animRootROI(nullptr), m_vehicleROI(nullptr), m_hiddenVehicleROI(nullptr), m_roiMap(nullptr), m_roiMapSize(0), + m_hasCamAnim(false), m_observerMode(false), m_hideOnStop(false) +{ +} + +ScenePlayer::~ScenePlayer() +{ + if (m_playing) { + Stop(); + } +} + +void ScenePlayer::SetupROIs(const AnimInfo* p_animInfo) +{ + LegoU32 numActors = m_currentData->anim->GetNumActors(); + std::vector createdROIs; + std::vector aliases; + std::deque aliasNames; + + std::vector participantMatched(m_participants.size(), false); + + auto addAlias = [&](const std::string& p_name, LegoROI* p_roi) { + aliasNames.push_back(p_name); + aliases.push_back({aliasNames.back().c_str(), p_roi}); + }; + + auto createProp = [&](const std::string& p_name, const char* p_lodName) -> LegoROI* { + char uniqueName[64]; + SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_prop_%s", p_name.c_str()); + LegoROI* roi = CharacterManager()->CreateAutoROI(uniqueName, p_lodName, FALSE); + if (roi) { + roi->SetName(p_name.c_str()); + createdROIs.push_back(roi); + } + return roi; + }; + + for (LegoU32 i = 0; i < numActors; i++) { + const char* actorName = m_currentData->anim->GetActorName(i); + LegoU32 actorType = m_currentData->anim->GetActorType(i); + + if (!actorName || *actorName == '\0') { + continue; + } + + const char* lookupName = (*actorName == '*') ? actorName + 1 : actorName; + std::string lowered(lookupName); + std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower); + + if (actorType == LegoAnimActorEntry::e_managedLegoActor) { + bool matched = false; + + for (size_t p = 0; p < m_participants.size(); p++) { + if (participantMatched[p] || m_participants[p].IsSpectator()) { + continue; + } + + if (MatchesCharacter(lowered, m_participants[p].charIndex)) { + participantMatched[p] = true; + matched = true; + addAlias(lowered, m_participants[p].roi); + break; + } + } + + if (matched) { + continue; + } + + // No participant matched — create a clone + char uniqueName[64]; + SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_char_%s", lowered.c_str()); + LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str()); + if (roi) { + roi->SetName(lowered.c_str()); + VideoManager()->Get3DManager()->Add(*roi); + createdROIs.push_back(roi); + } + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoiTrimmed || actorType == LegoAnimActorEntry::e_sceneRoi1 || actorType == LegoAnimActorEntry::e_sceneRoi2) { + createProp(lowered, AnimUtils::TrimLODSuffix(lowered).c_str()); + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoi) { + createProp(lowered, lowered.c_str()); + } + else { + // Type 0/1: check if this is a vehicle actor via ModelInfo flag + LegoROI* roi = nullptr; + bool isVehicleActor = false; + + for (uint8_t m = 0; m < p_animInfo->m_modelCount; m++) { + if (p_animInfo->m_models[m].m_name && + !SDL_strcasecmp(lowered.c_str(), p_animInfo->m_models[m].m_name) && + p_animInfo->m_models[m].m_unk0x2c) { + isVehicleActor = true; + break; + } + } + + // Try matching a participant's vehicle by category + if (isVehicleActor && !m_vehicleROI) { + MxU32 animVehicleIdx; + if (AnimationManager()->FindVehicle(lowered.c_str(), animVehicleIdx)) { + for (size_t p = 0; p < m_participants.size(); p++) { + if (!m_participants[p].vehicleROI) { + continue; + } + + MxU32 perfVehicleIdx; + if (AnimationManager()->FindVehicle(m_participants[p].vehicleROI->GetName(), perfVehicleIdx)) { + if (GetVehicleCategory(animVehicleIdx) == GetVehicleCategory(perfVehicleIdx)) { + m_vehicleROI = m_participants[p].vehicleROI; + addAlias(lowered, m_vehicleROI); + roi = m_vehicleROI; + break; + } + } + } + } + } + + // Try creating as prop + if (!roi) { + roi = createProp(lowered, AnimUtils::TrimLODSuffix(lowered).c_str()); + } + + // Final fallback: borrow local player's vehicle via alias + if (!roi && m_participants[0].vehicleROI && !m_vehicleROI) { + m_vehicleROI = m_participants[0].vehicleROI; + addAlias(lowered, m_vehicleROI); + } + } + } + + m_propROIs = std::move(createdROIs); + + // Find root ROI: first non-spectator participant matched to an animation actor + LegoROI* rootROI = nullptr; + for (size_t p = 0; p < m_participants.size(); p++) { + if (!m_participants[p].IsSpectator() && participantMatched[p]) { + rootROI = m_participants[p].roi; + break; + } + } + + if (!rootROI && !m_participants.empty()) { + rootROI = m_participants[0].roi; + } + + if (!rootROI) { + return; + } + + m_animRootROI = rootROI; + + // Collect extra ROIs (other matched participants + props + vehicle) + std::vector extras; + for (size_t p = 0; p < m_participants.size(); p++) { + if (m_participants[p].roi != rootROI && participantMatched[p]) { + extras.push_back(m_participants[p].roi); + } + } + for (auto* propROI : m_propROIs) { + extras.push_back(propROI); + } + if (m_vehicleROI) { + extras.push_back(m_vehicleROI); + } + + delete[] m_roiMap; + m_roiMap = nullptr; + m_roiMapSize = 0; + + AnimUtils::BuildROIMap( + m_currentData->anim, + rootROI, + extras.empty() ? nullptr : extras.data(), + (int) extras.size(), + m_roiMap, + m_roiMapSize, + aliases.empty() ? nullptr : aliases.data(), + (int) aliases.size() + ); +} + +void ScenePlayer::Play( + const AnimInfo* p_animInfo, + AnimCategory p_category, + const ParticipantROI* p_participants, + uint8_t p_participantCount, + bool p_observerMode +) +{ + if (m_playing) { + Stop(); + } + + if (p_participantCount == 0 || !p_participants[0].roi || !p_animInfo) { + return; + } + + SceneAnimData* data = m_loader.EnsureCached(p_animInfo->m_objectId); + if (!data || !data->anim) { + return; + } + + m_currentData = data; + m_category = p_category; + m_hideOnStop = data->hideOnStop; + m_observerMode = p_observerMode; + + // Build participant list with saved transforms for restoration + for (uint8_t i = 0; i < p_participantCount; i++) { + ParticipantROI participant; + participant.roi = p_participants[i].roi; + participant.vehicleROI = p_participants[i].vehicleROI; + participant.savedTransform = p_participants[i].roi->GetLocal2World(); + participant.savedName = p_participants[i].roi->GetName(); + participant.charIndex = p_participants[i].charIndex; + m_participants.push_back(participant); + } + + SetupROIs(p_animInfo); + + if (!m_roiMap) { + m_currentData = nullptr; + m_participants.clear(); + return; + } + + ResolvePtAtCamROIs(); + m_phonemePlayer.Init(data->phonemeTracks, m_roiMap, m_roiMapSize); + m_audioPlayer.Init(data->audioTracks); + + // Observers don't get camera control — they watch the animation from their own viewpoint + m_hasCamAnim = (!m_observerMode && m_category == e_camAnim && m_currentData->anim->GetCamAnim() != nullptr); + + if (m_category == e_camAnim && !m_observerMode) { + for (auto& p : m_participants) { + if (p.IsSpectator()) { + p.roi->SetVisibility(FALSE); + } + } + + // Hide the player's ride vehicle — it would remain visible at the + // pre-animation position while the player is teleported + LegoROI* localVehicle = m_participants[0].vehicleROI; + if (localVehicle && localVehicle != m_vehicleROI) { + localVehicle->SetVisibility(FALSE); + m_hiddenVehicleROI = localVehicle; + } + } + + m_startTime = 0; + m_playing = true; +} + +void ScenePlayer::ComputeRebaseMatrix() +{ + if (!m_animRootROI) { + m_rebaseMatrix.SetIdentity(); + m_rebaseComputed = true; + return; + } + + // Use the root performer's saved position as the rebase anchor + MxMatrix targetTransform; + targetTransform.SetIdentity(); + for (const auto& p : m_participants) { + if (p.roi == m_animRootROI) { + targetTransform = p.savedTransform; + break; + } + } + + // Find the root ROI's world transform at time 0 by walking the animation tree + std::function findOrigin = [&](LegoTreeNode* node, MxMatrix& parentWorld) -> bool { + LegoAnimNodeData* data = (LegoAnimNodeData*) node->GetData(); + MxU32 roiIdx = data ? data->GetROIIndex() : 0; + + MxMatrix localMat; + LegoROI::CreateLocalTransform(data, 0, localMat); + MxMatrix worldMat; + worldMat.Product(localMat, parentWorld); + + if (roiIdx != 0 && m_roiMap[roiIdx] == m_animRootROI) { + m_animPose0 = worldMat; + return true; + } + for (LegoU32 i = 0; i < node->GetNumChildren(); i++) { + if (findOrigin(node->GetChild(i), worldMat)) { + return true; + } + } + return false; + }; + MxMatrix identity; + identity.SetIdentity(); + findOrigin(m_currentData->anim->GetRoot(), identity); + + // Inverse of animPose0 (rigid body: transpose rotation, negate translated position) + MxMatrix invAnimPose0; + invAnimPose0.SetIdentity(); + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) { + invAnimPose0[r][c] = m_animPose0[c][r]; + } + } + for (int r = 0; r < 3; r++) { + invAnimPose0[3][r] = + -(invAnimPose0[0][r] * m_animPose0[3][0] + invAnimPose0[1][r] * m_animPose0[3][1] + + invAnimPose0[2][r] * m_animPose0[3][2]); + } + + m_rebaseMatrix.Product(invAnimPose0, targetTransform); + m_rebaseComputed = true; +} + +void ScenePlayer::ResolvePtAtCamROIs() +{ + m_ptAtCamROIs.clear(); + if (!m_currentData || m_currentData->ptAtCamNames.empty() || !m_roiMap) { + return; + } + + for (const auto& name : m_currentData->ptAtCamNames) { + for (MxU32 i = 1; i < m_roiMapSize; i++) { + if (m_roiMap[i] && m_roiMap[i]->GetName() && !SDL_strcasecmp(name.c_str(), m_roiMap[i]->GetName())) { + m_ptAtCamROIs.push_back(m_roiMap[i]); + break; + } + } + } +} + +void ScenePlayer::ApplyPtAtCam() +{ + if (m_ptAtCamROIs.empty()) { + return; + } + + LegoWorld* world = CurrentWorld(); + if (!world || !world->GetCameraController()) { + return; + } + + // Same math as LegoAnimPresenter::PutFrame + for (LegoROI* roi : m_ptAtCamROIs) { + if (!roi) { + continue; + } + + MxMatrix mat(roi->GetLocal2World()); + + Vector3 pos(mat[0]); + Vector3 dir(mat[1]); + Vector3 up(mat[2]); + Vector3 und(mat[3]); + + float possqr = sqrt(pos.LenSquared()); + float dirsqr = sqrt(dir.LenSquared()); + float upsqr = sqrt(up.LenSquared()); + + up = und; + up -= world->GetCameraController()->GetWorldLocation(); + dir /= dirsqr; + pos.EqualsCross(dir, up); + pos.Unitize(); + up.EqualsCross(pos, dir); + pos *= possqr; + dir *= dirsqr; + up *= upsqr; + + roi->SetLocal2World(mat); + roi->WrappedUpdateWorldData(); + } +} + +void ScenePlayer::Tick() +{ + if (!m_playing || !m_currentData || m_participants.empty()) { + return; + } + + if (m_startTime == 0) { + m_startTime = SDL_GetTicks(); + } + + if (m_category == e_npcAnim && m_roiMap) { + AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize); + } + + float elapsed = (float) (SDL_GetTicks() - m_startTime); + + if (elapsed >= m_currentData->duration) { + Stop(); + return; + } + + // 1. Skeletal animation + if (m_currentData->anim && m_roiMap) { + if (!m_rebaseComputed) { + if (m_category == e_camAnim) { + // cam_anims use the action transform directly (keyframes are in world space) + if (m_currentData->actionTransform.valid) { + Mx3DPointFloat loc( + m_currentData->actionTransform.location[0], + m_currentData->actionTransform.location[1], + m_currentData->actionTransform.location[2] + ); + Mx3DPointFloat dir( + m_currentData->actionTransform.direction[0], + m_currentData->actionTransform.direction[1], + m_currentData->actionTransform.direction[2] + ); + Mx3DPointFloat up( + m_currentData->actionTransform.up[0], + m_currentData->actionTransform.up[1], + m_currentData->actionTransform.up[2] + ); + CalcLocalTransform(loc, dir, up, m_rebaseMatrix); + } + else { + m_rebaseMatrix.SetIdentity(); + } + m_rebaseComputed = true; + } + else { + ComputeRebaseMatrix(); + } + } + + AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap); + } + + // 2. Camera animation (cam_anim only) + if (m_hasCamAnim) { + MxMatrix camTransform(m_rebaseMatrix); + m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform); + + LegoWorld* world = CurrentWorld(); + if (world && world->GetCameraController()) { + world->GetCameraController()->TransformPointOfView(camTransform, FALSE); + } + } + + // 3. PTATCAM post-processing + ApplyPtAtCam(); + + // 4. Audio + const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr; + m_audioPlayer.Tick(elapsed, audioROIName); + + // 5. Phoneme frames + m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks); +} + +void ScenePlayer::Stop() +{ + if (!m_playing) { + return; + } + + m_audioPlayer.Cleanup(); + m_phonemePlayer.Cleanup(); + + if (m_hideOnStop && m_roiMap) { + for (MxU32 i = 1; i < m_roiMapSize; i++) { + if (m_roiMap[i]) { + m_roiMap[i]->SetVisibility(FALSE); + } + } + } + + if (m_hiddenVehicleROI) { + m_hiddenVehicleROI->SetVisibility(TRUE); + m_hiddenVehicleROI = nullptr; + } + + CleanupProps(); + m_vehicleROI = nullptr; + + delete[] m_roiMap; + m_roiMap = nullptr; + m_roiMapSize = 0; + + for (auto& p : m_participants) { + p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform); + p.roi->SetVisibility(TRUE); + } + m_participants.clear(); + + m_ptAtCamROIs.clear(); + m_playing = false; + m_rebaseComputed = false; + m_currentData = nullptr; + m_animRootROI = nullptr; + m_hasCamAnim = false; + m_observerMode = false; + m_startTime = 0; + m_hideOnStop = false; +} + +void ScenePlayer::CleanupProps() +{ + for (auto* propROI : m_propROIs) { + if (propROI) { + CharacterManager()->ReleaseAutoROI(propROI); + } + } + m_propROIs.clear(); +} diff --git a/extensions/src/multiplayer/animation/sessionhost.cpp b/extensions/src/multiplayer/animation/sessionhost.cpp new file mode 100644 index 00000000..343a77e8 --- /dev/null +++ b/extensions/src/multiplayer/animation/sessionhost.cpp @@ -0,0 +1,310 @@ +#include "extensions/multiplayer/animation/sessionhost.h" + +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/coordinator.h" + +#include + +using namespace Multiplayer::Animation; + +static bool HasAnyFilledSlot(const AnimSession& p_session) +{ + for (const auto& slot : p_session.slots) { + if (slot.peerId != 0) { + return true; + } + } + return false; +} + +void SessionHost::SetCatalog(const Catalog* p_catalog) +{ + m_catalog = p_catalog; +} + +AnimSession SessionHost::CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex) +{ + AnimSession session; + session.animIndex = p_animIndex; + session.state = CoordinationState::e_interested; + session.countdownEndTime = 0; + + for (int8_t i : GetPerformerIndices(p_entry->performerMask)) { + SessionSlot slot; + slot.peerId = 0; + slot.charIndex = i; + session.slots.push_back(slot); + } + + SessionSlot spectatorSlot; + spectatorSlot.peerId = 0; + spectatorSlot.charIndex = -1; + session.slots.push_back(spectatorSlot); + + return session; +} + +bool SessionHost::TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex) +{ + for (const auto& slot : p_session.slots) { + if (slot.peerId == p_peerId) { + return false; + } + } + + // Performer slots first + for (auto& slot : p_session.slots) { + if (!slot.IsSpectator() && slot.peerId == 0 && slot.charIndex == p_charIndex) { + slot.peerId = p_peerId; + return true; + } + } + + // Spectator slot + if (!m_catalog) { + return false; + } + + const CatalogEntry* entry = m_catalog->FindEntry(p_session.animIndex); + if (!entry) { + return false; + } + + for (auto& slot : p_session.slots) { + if (slot.IsSpectator() && slot.peerId == 0) { + if (p_charIndex >= 0 && !((entry->performerMask >> p_charIndex) & 1) && + Catalog::CheckSpectatorMask(entry, p_charIndex)) { + slot.peerId = p_peerId; + return true; + } + break; + } + } + + return false; +} + +bool SessionHost::AllSlotsFilled(const AnimSession& p_session) const +{ + for (const auto& slot : p_session.slots) { + if (slot.peerId == 0) { + return false; + } + } + return true; +} + +void SessionHost::RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector& p_changedAnims) +{ + RemovePlayerFromSessions(p_peerId, false, p_changedAnims); +} + +void SessionHost::RemovePlayerFromSessions( + uint32_t p_peerId, + bool p_includePlayingSessions, + std::vector& p_changedAnims +) +{ + std::vector toErase; + + for (auto& [animIndex, session] : m_sessions) { + if (!p_includePlayingSessions && session.state == CoordinationState::e_playing) { + continue; + } + + bool found = false; + for (auto& slot : session.slots) { + if (slot.peerId == p_peerId) { + slot.peerId = 0; + found = true; + break; + } + } + + if (found) { + if (session.state == CoordinationState::e_countdown) { + session.state = CoordinationState::e_interested; + session.countdownEndTime = 0; + } + + if (!HasAnyFilledSlot(session)) { + toErase.push_back(animIndex); + } + + p_changedAnims.push_back(animIndex); + } + } + + for (uint16_t idx : toErase) { + m_sessions.erase(idx); + } +} + +bool SessionHost::HandleInterest( + uint32_t p_peerId, + uint16_t p_animIndex, + uint8_t p_displayActorIndex, + std::vector& p_changedAnims +) +{ + if (!m_catalog) { + return false; + } + + int8_t charIndex = Catalog::DisplayActorToCharacterIndex(p_displayActorIndex); + + RemovePlayerFromAllSessions(p_peerId, p_changedAnims); + + const CatalogEntry* entry = m_catalog->FindEntry(p_animIndex); + if (!entry) { + return !p_changedAnims.empty(); + } + + auto it = m_sessions.find(p_animIndex); + if (it == m_sessions.end()) { + m_sessions[p_animIndex] = CreateSession(entry, p_animIndex); + it = m_sessions.find(p_animIndex); + } + + bool assigned = TryAssignSlot(it->second, p_peerId, charIndex); + + // Always broadcast: on success the new slot is shown, on failure the rejected + // player's client receives the session state and clears their optimistic interest. + p_changedAnims.push_back(p_animIndex); + + // Clean up empty sessions (created but no one could fill a slot) + if (!assigned) { + if (!HasAnyFilledSlot(it->second)) { + m_sessions.erase(it); + } + } + + return !p_changedAnims.empty(); +} + +bool SessionHost::HandleCancel(uint32_t p_peerId, std::vector& p_changedAnims) +{ + RemovePlayerFromSessions(p_peerId, true, p_changedAnims); + + // Explicit cancel during playback: erase entire session so all participants stop + for (uint16_t animIndex : p_changedAnims) { + auto it = m_sessions.find(animIndex); + if (it != m_sessions.end() && it->second.state == CoordinationState::e_playing) { + m_sessions.erase(it); + } + } + + return !p_changedAnims.empty(); +} + +bool SessionHost::HandlePlayerRemoved(uint32_t p_peerId, std::vector& p_changedAnims) +{ + RemovePlayerFromSessions(p_peerId, true, p_changedAnims); + return !p_changedAnims.empty(); +} + +void SessionHost::StartCountdown(uint16_t p_animIndex) +{ + auto it = m_sessions.find(p_animIndex); + if (it != m_sessions.end() && it->second.state == CoordinationState::e_interested) { + it->second.state = CoordinationState::e_countdown; + it->second.countdownEndTime = SDL_GetTicks() + COUNTDOWN_DURATION_MS; + } +} + +void SessionHost::RevertCountdown(uint16_t p_animIndex) +{ + auto it = m_sessions.find(p_animIndex); + if (it != m_sessions.end() && it->second.state == CoordinationState::e_countdown) { + it->second.state = CoordinationState::e_interested; + it->second.countdownEndTime = 0; + } +} + +uint16_t SessionHost::Tick(uint32_t p_now) +{ + for (auto& [animIndex, session] : m_sessions) { + if (session.state == CoordinationState::e_countdown && p_now >= session.countdownEndTime) { + session.state = CoordinationState::e_playing; + return animIndex; + } + } + + return ANIM_INDEX_NONE; +} + +void SessionHost::Reset() +{ + m_sessions.clear(); +} + +void SessionHost::EraseSession(uint16_t p_animIndex) +{ + m_sessions.erase(p_animIndex); +} + +const AnimSession* SessionHost::FindSession(uint16_t p_animIndex) const +{ + auto it = m_sessions.find(p_animIndex); + if (it != m_sessions.end()) { + return &it->second; + } + return nullptr; +} + +const std::map& SessionHost::GetSessions() const +{ + return m_sessions; +} + +bool SessionHost::AreAllSlotsFilled(uint16_t p_animIndex) const +{ + auto it = m_sessions.find(p_animIndex); + if (it == m_sessions.end()) { + return false; + } + return AllSlotsFilled(it->second); +} + +uint16_t SessionHost::ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now) +{ + if (p_session.state != CoordinationState::e_countdown) { + return 0; + } + + if (p_now >= p_session.countdownEndTime) { + return 0; + } + + uint32_t remaining = p_session.countdownEndTime - p_now; + if (remaining > 0xFFFF) { + return 0xFFFF; + } + return static_cast(remaining); +} + +bool SessionHost::HasCountdownSession() const +{ + for (const auto& [animIndex, session] : m_sessions) { + if (session.state == CoordinationState::e_countdown) { + return true; + } + } + return false; +} + +std::vector SessionHost::ComputeSlotCharIndices(const CatalogEntry* p_entry) +{ + std::vector indices; + if (!p_entry) { + return indices; + } + + // Performers: one slot per set bit in performerMask (same order as CreateSession) + indices = GetPerformerIndices(p_entry->performerMask); + + // Spectator slot last + indices.push_back(-1); + + return indices; +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 1244fde9..e1b9f4d1 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -1,11 +1,12 @@ #include "extensions/multiplayer/networkmanager.h" -#include "extensions/common/animdata.h" #include "extensions/common/arearestriction.h" #include "extensions/common/charactercustomizer.h" +#include "extensions/common/charactertables.h" #include "extensions/multiplayer/namebubblerenderer.h" #include "extensions/thirdpersoncamera.h" #include "extensions/thirdpersoncamera/controller.h" +#include "legoactor.h" #include "legoanimationmanager.h" #include "legocharactermanager.h" #include "legoextraactor.h" @@ -30,6 +31,23 @@ using Common::IsMultiPartEmote; using Common::IsRestrictedArea; using Common::WORLD_NOT_VISIBLE; +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +// Slightly larger than NPC_ANIM_PROXIMITY to catch transitions +static constexpr float NPC_ANIM_NEARBY_RADIUS_SQ = + (Animation::NPC_ANIM_PROXIMITY + 5.0f) * (Animation::NPC_ANIM_PROXIMITY + 5.0f); + +static const char* IDLE_ANIM_STATE_JSON = + "{\"location\":-1,\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}"; + +static void ExtractSlotPeerIds(const AnimUpdateMsg& p_msg, uint32_t p_out[8]) +{ + for (uint8_t i = 0; i < 8; i++) { + p_out[i] = (i < p_msg.slotCount) ? p_msg.slots[i].peerId : 0; + } +} + template void NetworkManager::SendMessage(const T& p_msg) { @@ -49,9 +67,11 @@ NetworkManager::NetworkManager() m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true), m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), - m_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false), - m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), m_reconnectDelay(0), - m_nextReconnectTime(0) + m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), + m_playingAnimIndex(Animation::ANIM_INDEX_NONE), m_disableAllNPCs(false), m_showNameBubbles(true), + m_lastCameraEnabled(false), m_wasInRestrictedArea(false), m_animStateDirty(false), m_animInterestDirty(false), + m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), + m_reconnectDelay(0), m_nextReconnectTime(0) { } @@ -80,8 +100,15 @@ MxResult NetworkManager::Tickle() bool cameraEnabled = cam->IsEnabled(); if (cameraEnabled != m_lastCameraEnabled) { m_lastCameraEnabled = cameraEnabled; + m_animStateDirty = true; NotifyThirdPersonChanged(cameraEnabled); + // Cancel animation when camera is disabled (vehicle entry, restricted area, etc.) + if (!cameraEnabled && m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + CancelLocalAnimInterest(); + StopScenePlayback(false); + } + if (m_localNameBubble) { if (!cameraEnabled) { m_localNameBubble->SetVisible(false); @@ -106,6 +133,42 @@ MxResult NetworkManager::Tickle() } } + // Update local player location proximity + if (m_inIsleWorld) { + LegoPathActor* userActor = UserActor(); + if (userActor && userActor->GetROI()) { + const float* pos = userActor->GetROI()->GetWorldPosition(); + if (m_locationProximity.Update(pos[0], pos[2])) { + int16_t loc = m_locationProximity.GetNearestLocation(); + m_animStateDirty = true; + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + m_animCoordinator.OnLocationChanged(loc, &m_animCatalog); + + // Location change cleared interest — send cancel to host + if (oldState != Animation::CoordinationState::e_idle && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + if (IsHost()) { + HandleAnimCancel(m_localPeerId); + } + else if (IsConnected()) { + AnimCancelMsg cancelMsg{}; + cancelMsg.header = {MSG_ANIM_CANCEL, m_localPeerId, m_sequence++, TARGET_HOST}; + SendMessage(cancelMsg); + } + m_localPendingAnimInterest = -1; + } + } + } + + if (IsHost()) { + TickHostSessions(); + } + else if (m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) { + m_animStateDirty = true; + } + } + if (!m_transport) { return SUCCESS; } @@ -121,6 +184,7 @@ MxResult NetworkManager::Tickle() ProcessIncomingPackets(); UpdateRemotePlayers(0.016f); + TickAnimation(); // Re-read time; ProcessIncomingPackets may have advanced SDL_GetTicks. uint32_t timeoutNow = SDL_GetTicks(); @@ -135,6 +199,18 @@ MxResult NetworkManager::Tickle() RemoveRemotePlayer(peerId); } + // Push animation state to frontend if dirty (throttled) + if (m_animStateDirty && m_inIsleWorld && m_callbacks) { + uint32_t pushNow = SDL_GetTicks(); + bool cooldownExpired = (pushNow - m_lastAnimPushTime) >= ANIM_PUSH_COOLDOWN_MS; + if (cooldownExpired || m_animInterestDirty) { + m_animStateDirty = false; + m_animInterestDirty = false; + m_lastAnimPushTime = pushNow; + PushAnimationState(); + } + } + return SUCCESS; } @@ -187,6 +263,7 @@ void NetworkManager::Disconnect() m_transport->Disconnect(); } RemoveAllRemotePlayers(); + ResetAnimationState(); } bool NetworkManager::IsConnected() const @@ -199,6 +276,55 @@ bool NetworkManager::WasRejected() const return m_wasRejected; } +void NetworkManager::ResetAnimationState() +{ + m_animCoordinator.Reset(); + m_animSessionHost.Reset(); + m_localPendingAnimInterest = -1; + m_pendingAnimInterest.store(-1, std::memory_order_relaxed); + m_pendingAnimCancel.store(false, std::memory_order_relaxed); + m_animStateDirty = true; +} + +void NetworkManager::BroadcastChangedSessions(const std::vector& p_changedAnims) +{ + for (uint16_t idx : p_changedAnims) { + BroadcastAnimUpdate(idx); + } + m_animStateDirty = true; +} + +void NetworkManager::CancelLocalAnimInterest() +{ + m_animCoordinator.ClearInterest(); + m_localPendingAnimInterest = -1; + + if (IsHost()) { + HandleAnimCancel(m_localPeerId); + } + else if (IsConnected()) { + AnimCancelMsg msg{}; + msg.header = {MSG_ANIM_CANCEL, m_localPeerId, m_sequence++, TARGET_HOST}; + SendMessage(msg); + } + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::StopAnimation() +{ + ResetAnimationState(); + + if (m_scenePlayer.IsPlaying()) { + m_scenePlayer.Stop(); + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + } +} + void NetworkManager::OnWorldEnabled(LegoWorld* p_world) { if (!p_world) { @@ -232,6 +358,15 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) if (m_disableAllNPCs) { EnforceDisableNPCs(); } + + // Refresh animation catalog from the animation manager + if (AnimationManager()) { + m_animCatalog.Refresh(AnimationManager()); + m_animCoordinator.SetCatalog(&m_animCatalog); + m_animSessionHost.SetCatalog(&m_animCatalog); + } + + m_locationProximity.Reset(); } } @@ -246,6 +381,16 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) m_wasInRestrictedArea = false; m_worldSync.SetInIsleWorld(false); + // Stop animation before ROIs are destroyed (calls ResetAnimationState) + StopAnimation(); + m_animStateDirty = false; // override: we push explicit empty JSON below + m_locationProximity.Reset(); + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable( + "{\"location\":-1,\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}" + ); + } + // Destroy local name bubble (ROI is about to be destroyed) if (m_localNameBubble) { m_localNameBubble->Destroy(); @@ -408,6 +553,7 @@ void NetworkManager::ResetStateAfterReconnect() m_sequence = 0; m_lastBroadcastTime = 0; m_worldSync.ResetForReconnect(); + ResetAnimationState(); } void NetworkManager::ProcessPendingRequests() @@ -419,12 +565,17 @@ void NetworkManager::ProcessPendingRequests() if (cam) { if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) { if (cam->IsEnabled()) { + if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + CancelLocalAnimInterest(); + StopScenePlayback(false); + } cam->Disable(); + NotifyThirdPersonChanged(false); } else { cam->Enable(); + NotifyThirdPersonChanged(true); } - NotifyThirdPersonChanged(cam->IsEnabled()); } int walkAnim = m_pendingWalkAnim.exchange(-1, std::memory_order_relaxed); @@ -443,6 +594,51 @@ void NetworkManager::ProcessPendingRequests() } } + int32_t animInterest = m_pendingAnimInterest.exchange(-1, std::memory_order_relaxed); + if (animInterest >= 0) { + // Discard during countdown or playback — player is committed + Animation::CoordinationState coordState = m_animCoordinator.GetState(); + bool canChangeInterest = + (coordState == Animation::CoordinationState::e_idle || + coordState == Animation::CoordinationState::e_interested); + + if (canChangeInterest) { + uint16_t animIndex = static_cast(animInterest); + m_animCoordinator.SetInterest(animIndex); + m_localPendingAnimInterest = animInterest; + + if (IsHost()) { + uint8_t displayActorIndex = 0; + ThirdPersonCamera::Controller* animCam = GetCamera(); + if (animCam) { + displayActorIndex = animCam->GetDisplayActorIndex(); + } + HandleAnimInterest(m_localPeerId, animIndex, displayActorIndex); + + // If slot assignment failed, clear optimistic interest + if (!m_animCoordinator.IsLocalPlayerInSession(animIndex)) { + m_animCoordinator.ClearInterest(); + m_localPendingAnimInterest = -1; + } + } + else if (IsConnected()) { + AnimInterestMsg msg{}; + msg.header = {MSG_ANIM_INTEREST, m_localPeerId, m_sequence++, TARGET_HOST}; + msg.animIndex = animIndex; + ThirdPersonCamera::Controller* animCam = GetCamera(); + msg.displayActorIndex = animCam ? animCam->GetDisplayActorIndex() : 0; + SendMessage(msg); + } + + m_animStateDirty = true; + m_animInterestDirty = true; + } + } + + if (m_pendingAnimCancel.exchange(false, std::memory_order_relaxed)) { + CancelLocalAnimInterest(); + } + if (m_pendingToggleAllowCustomize.exchange(false, std::memory_order_relaxed)) { m_localAllowRemoteCustomize = !m_localAllowRemoteCustomize; NotifyAllowCustomizeChanged(m_localAllowRemoteCustomize); @@ -528,8 +724,8 @@ void NetworkManager::BroadcastLocalState() msg.customizeFlags |= (frozenId & 0x07) << 2; } - // Zero speed when in any phase of a multi-part emote - if (cam->IsInMultiPartEmote()) { + // Zero speed when in any phase of a multi-part emote or animation playback + if (cam->IsInMultiPartEmote() || cam->IsAnimPlaying()) { msg.speed = 0.0f; } } @@ -555,6 +751,7 @@ void NetworkManager::ProcessIncomingPackets() SDL_memcpy(&assignedId, data + 1, sizeof(uint32_t)); m_localPeerId = assignedId; m_worldSync.SetLocalPeerId(assignedId); + m_animCoordinator.SetLocalPeerId(assignedId); } if (length >= 6) { uint8_t maxActors = data[5]; @@ -633,6 +830,34 @@ void NetworkManager::ProcessIncomingPackets() } break; } + case MSG_ANIM_INTEREST: { + AnimInterestMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_INTEREST) { + HandleAnimInterest(msg.header.peerId, msg.animIndex, msg.displayActorIndex); + } + break; + } + case MSG_ANIM_CANCEL: { + AnimCancelMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_CANCEL) { + HandleAnimCancel(msg.header.peerId); + } + break; + } + case MSG_ANIM_UPDATE: { + AnimUpdateMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_UPDATE) { + HandleAnimUpdate(msg); + } + break; + } + case MSG_ANIM_START: { + AnimStartMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_START) { + HandleAnimStart(msg); + } + break; + } default: break; } @@ -641,8 +866,32 @@ void NetworkManager::ProcessIncomingPackets() void NetworkManager::UpdateRemotePlayers(float p_deltaTime) { + float radius = m_locationProximity.GetRadius(); + int16_t localLoc = m_locationProximity.GetNearestLocation(); + bool anyInIsle = false; + for (auto& [peerId, player] : m_remotePlayers) { player->Tick(p_deltaTime); + + // Derive nearest location from remote player's current position + // Skip players not in the isle world — their position is stale + if (player->IsSpawned() && player->GetROI() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) { + anyInIsle = true; + + int16_t oldLoc = player->GetNearestLocation(); + const float* pos = player->GetROI()->GetWorldPosition(); + int16_t newLoc = Animation::LocationProximity::ComputeNearest(pos[0], pos[2], radius); + player->SetNearestLocation(newLoc); + if (oldLoc != newLoc && (oldLoc == localLoc || newLoc == localLoc)) { + m_animStateDirty = true; + } + } + } + + // Keep pushing while remote players are in the isle world so proximity-based + // eligibility and session display stay up to date as players move around + if (anyInIsle) { + m_animStateDirty = true; } } @@ -685,6 +934,13 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex); NotifyPlayerCountChanged(); it = m_remotePlayers.find(peerId); + + // Send existing session state so the new player sees active sessions + if (IsHost()) { + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + SendAnimUpdateToPlayer(animIndex, peerId); + } + } } // Respawn only if display actor changed (not on actorId change) @@ -696,6 +952,14 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) m_remotePlayers.erase(it); CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex); it = m_remotePlayers.find(peerId); + m_animStateDirty = true; + + if (IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } } else if (IsValidActorId(p_msg.actorId)) { it->second->SetActorId(p_msg.actorId); // Update for future use, no visual change @@ -715,6 +979,15 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) bool nowInIsle = (p_msg.worldId == (int8_t) LegoOmni::e_act1); if (m_inIsleWorld && wasInIsle != nowInIsle) { NotifyPlayerCountChanged(); + m_animStateDirty = true; + + // Player left the isle world — remove from animation sessions + if (wasInIsle && !nowInIsle && IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } } } @@ -725,8 +998,15 @@ void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg) m_worldSync.SetHost(IsHost()); - if (!IsHost() && oldHost != m_hostPeerId) { - m_worldSync.OnHostChanged(); + if (oldHost != m_hostPeerId) { + if (!IsHost()) { + m_worldSync.OnHostChanged(); + } + // Reset coordination on actual host change, not initial assignment. + // Initial assignment (oldHost==0) may race with session updates from the host. + if (oldHost != 0) { + ResetAnimationState(); + } } } @@ -784,12 +1064,22 @@ void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) { auto it = m_remotePlayers.find(p_peerId); if (it != m_remotePlayers.end()) { + if (it->second->GetNearestLocation() == m_locationProximity.GetNearestLocation()) { + m_animStateDirty = true; + } if (it->second->GetROI()) { m_roiToPlayer.erase(it->second->GetROI()); } it->second->Despawn(); m_remotePlayers.erase(it); NotifyPlayerCountChanged(); + + if (IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(p_peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } } } @@ -800,6 +1090,7 @@ void NetworkManager::RemoveAllRemotePlayers() } m_remotePlayers.clear(); m_roiToPlayer.clear(); + m_animStateDirty = true; NotifyPlayerCountChanged(); } @@ -893,6 +1184,511 @@ void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType SendMessage(msg); } +void NetworkManager::StopScenePlayback(bool p_unlockRemotes) +{ + if (!m_scenePlayer.IsPlaying()) { + return; + } + + m_scenePlayer.Stop(); + m_playingAnimIndex = Animation::ANIM_INDEX_NONE; + + if (p_unlockRemotes) { + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } +} + +void NetworkManager::TickAnimation() +{ + if (!m_scenePlayer.IsPlaying()) { + return; + } + + m_scenePlayer.Tick(); + + if (!m_scenePlayer.IsPlaying()) { + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + + if (IsHost() && m_playingAnimIndex != Animation::ANIM_INDEX_NONE) { + m_animSessionHost.EraseSession(m_playingAnimIndex); + BroadcastAnimUpdate(m_playingAnimIndex); // Broadcast cleared state + } + + m_playingAnimIndex = Animation::ANIM_INDEX_NONE; + m_animCoordinator.Reset(); + m_animStateDirty = true; + m_animInterestDirty = true; + } +} + +void NetworkManager::TickHostSessions() +{ + // Check co-location for all sessions: start/revert countdown as needed. + // For cam anims, also auto-remove players who left the required location. + // Use a snapshot of keys since we may modify sessions during iteration. + std::vector sessionKeys; + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + sessionKeys.push_back(animIndex); + } + + for (uint16_t animIndex : sessionKeys) { + const Animation::AnimSession* session = m_animSessionHost.FindSession(animIndex); + if (!session || session->state == Animation::CoordinationState::e_playing) { + continue; + } + + // For cam anims: auto-remove players who left the required location + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(animIndex); + if (entry && entry->location >= 0) { + std::vector toRemove; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && GetPeerLocation(slot.peerId) != entry->location) { + toRemove.push_back(slot.peerId); + } + } + for (uint32_t pid : toRemove) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + session = m_animSessionHost.FindSession(animIndex); + if (!session) { + continue; + } + } + + bool allFilled = m_animSessionHost.AreAllSlotsFilled(animIndex); + bool coLocated = allFilled && ValidateSessionLocations(animIndex); + + if (session->state == Animation::CoordinationState::e_interested && coLocated) { + m_animSessionHost.StartCountdown(animIndex); + + if (m_animCoordinator.IsLocalPlayerInSession(animIndex)) { + const AnimInfo* ai = m_animCatalog.GetAnimInfo(animIndex); + if (ai) { + m_scenePlayer.PreloadAsync(ai->m_objectId); + } + } + + BroadcastAnimUpdate(animIndex); + m_animStateDirty = true; + } + else if (session->state == Animation::CoordinationState::e_countdown && !coLocated) { + m_animSessionHost.RevertCountdown(animIndex); + BroadcastAnimUpdate(animIndex); + m_animStateDirty = true; + } + } + + // Check countdown expiry + uint16_t readyAnim = m_animSessionHost.Tick(SDL_GetTicks()); + if (readyAnim != Animation::ANIM_INDEX_NONE) { + BroadcastAnimStart(readyAnim); + HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim)); + } + + // During countdown, push state every tick so countdownMs reaches the frontend + if (m_animSessionHost.HasCountdownSession()) { + m_animStateDirty = true; + } +} + +void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex) +{ + if (!IsHost()) { + return; + } + + // For location-bound animations, player must be at that location + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (entry && entry->location >= 0) { + if (GetPeerLocation(p_peerId) != entry->location) { + return; + } + } + + // For NPC anims: if all slots are full, remove far-away participants to make room + // for the new nearby player. This only fires when slots are exhausted — if there's + // an open slot, the new player just joins normally without disturbing anyone. + if (entry && entry->location == -1 && m_animSessionHost.AreAllSlotsFilled(p_animIndex)) { + float newX, newZ; + if (GetPeerPosition(p_peerId, newX, newZ)) { + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (session) { + std::vector stale; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && slot.peerId != p_peerId && !IsPeerNearby(slot.peerId, newX, newZ)) { + stale.push_back(slot.peerId); + } + } + for (uint32_t pid : stale) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + } + } + } + + std::vector changedAnims; + if (m_animSessionHost.HandleInterest(p_peerId, p_animIndex, p_displayActorIndex, changedAnims)) { + BroadcastChangedSessions(changedAnims); + m_animInterestDirty = true; + } +} + +void NetworkManager::HandleAnimCancel(uint32_t p_peerId) +{ + if (!IsHost()) { + return; + } + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + + std::vector changedAnims; + if (m_animSessionHost.HandleCancel(p_peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + m_animInterestDirty = true; + } + + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + StopScenePlayback(true); + } +} + +void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg) +{ + if (IsHost()) { + return; // Host already updated its own state + } + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + + uint32_t slots[8]; + ExtractSlotPeerIds(p_msg, slots); + + m_animCoordinator.ApplySessionUpdate(p_msg.animIndex, p_msg.state, p_msg.countdownMs, slots, p_msg.slotCount); + + if (p_msg.state == static_cast(Animation::CoordinationState::e_countdown)) { + const AnimInfo* ai = m_animCatalog.GetAnimInfo(p_msg.animIndex); + if (ai) { + m_scenePlayer.PreloadAsync(ai->m_objectId); + } + } + + // If local player's pending interest matches, clear it (host has responded) + if (m_localPendingAnimInterest >= 0 && static_cast(m_localPendingAnimInterest) == p_msg.animIndex) { + m_localPendingAnimInterest = -1; + } + + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + StopScenePlayback(true); + } + + // Stop observer playback when the observed session is cleared + if (m_scenePlayer.IsPlaying() && m_playingAnimIndex == p_msg.animIndex && p_msg.state == 0) { + StopScenePlayback(true); + } + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::HandleAnimStart(const AnimStartMsg& p_msg) +{ + if (IsHost()) { + return; // Host handles locally in BroadcastAnimStart + } + + m_animCoordinator.ApplyAnimStart(p_msg.animIndex); + HandleAnimStartLocally(p_msg.animIndex, m_animCoordinator.IsLocalPlayerInSession(p_msg.animIndex)); + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession) +{ + auto abortSession = [&]() { + // Observers must not abort the authoritative session — only participants may do that + if (p_localInSession) { + if (IsHost()) { + m_animSessionHost.EraseSession(p_animIndex); + BroadcastAnimUpdate(p_animIndex); + } + m_animCoordinator.Reset(); + } + m_animStateDirty = true; + }; + + const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex); + if (!animInfo) { + abortSession(); + return; + } + + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (!entry) { + abortSession(); + return; + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (p_localInSession && (!cam || !cam->GetDisplayROI())) { + abortSession(); + return; + } + + const Animation::SessionView* view = m_animCoordinator.GetSessionView(p_animIndex); + std::vector slotChars = Animation::SessionHost::ComputeSlotCharIndices(entry); + + bool observerMode = !p_localInSession; + + // Build participants: local player first (if participating), then remotes + int8_t localCharIndex = -1; + std::vector participants; + + if (view) { + uint8_t count = view->slotCount < (uint8_t) slotChars.size() ? view->slotCount : (uint8_t) slotChars.size(); + for (uint8_t i = 0; i < count; i++) { + uint32_t peerId = view->peerSlots[i]; + if (peerId == 0) { + continue; + } + + if (peerId == m_localPeerId) { + localCharIndex = slotChars[i]; + continue; + } + + auto it = m_remotePlayers.find(peerId); + if (it == m_remotePlayers.end() || !it->second->GetROI()) { + continue; + } + + Animation::ParticipantROI rp; + rp.roi = it->second->GetROI(); + rp.vehicleROI = it->second->GetRideVehicleROI(); + rp.charIndex = slotChars[i]; + participants.push_back(rp); + + // Lock performers to prevent network updates from fighting animation + if (!rp.IsSpectator()) { + it->second->SetAnimationLocked(true); + } + } + } + + // Insert local player at index 0 only when participating + if (!observerMode) { + Animation::ParticipantROI local; + local.roi = cam->GetDisplayROI(); + local.vehicleROI = cam->GetRideVehicleROI(); + local.charIndex = localCharIndex; + participants.insert(participants.begin(), local); + } + + if (participants.empty()) { + abortSession(); + return; + } + + if (!observerMode) { + bool localIsPerformer = (localCharIndex >= 0); + cam->SetAnimPlaying(true, localIsPerformer, [this]() { m_scenePlayer.Stop(); }); + } + + m_scenePlayer.Play(animInfo, entry->category, participants.data(), (uint8_t) participants.size(), observerMode); + + if (!m_scenePlayer.IsPlaying()) { + if (!observerMode) { + cam->SetAnimPlaying(false); + } + // Unlock remote players on failure + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + abortSession(); + return; + } + + m_playingAnimIndex = p_animIndex; + m_localPendingAnimInterest = -1; + m_animStateDirty = true; +} + +AnimUpdateMsg NetworkManager::BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target) +{ + AnimUpdateMsg msg{}; + msg.header = {MSG_ANIM_UPDATE, m_localPeerId, m_sequence++, p_target}; + msg.animIndex = p_animIndex; + + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (session) { + msg.state = static_cast(session->state); + msg.countdownMs = Animation::SessionHost::ComputeCountdownMs(*session, SDL_GetTicks()); + msg.slotCount = static_cast(session->slots.size() < 8 ? session->slots.size() : 8); + for (uint8_t i = 0; i < msg.slotCount; i++) { + msg.slots[i].peerId = session->slots[i].peerId; + } + } + // else: zero-initialized = cleared state + return msg; +} + +void NetworkManager::BroadcastAnimUpdate(uint16_t p_animIndex) +{ + AnimUpdateMsg msg = BuildAnimUpdateMsg(p_animIndex, TARGET_BROADCAST); + SendMessage(msg); + + // Also update local coordinator + uint32_t slots[8]; + ExtractSlotPeerIds(msg, slots); + m_animCoordinator.ApplySessionUpdate(msg.animIndex, msg.state, msg.countdownMs, slots, msg.slotCount); +} + +void NetworkManager::SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId) +{ + SendMessage(BuildAnimUpdateMsg(p_animIndex, p_targetPeerId)); +} + +void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex) +{ + AnimStartMsg msg{}; + msg.header = {MSG_ANIM_START, m_localPeerId, m_sequence++, TARGET_BROADCAST}; + msg.animIndex = p_animIndex; + SendMessage(msg); + + // Also update local coordinator + m_animCoordinator.ApplyAnimStart(p_animIndex); +} + +int16_t NetworkManager::GetPeerLocation(uint32_t p_peerId) const +{ + if (p_peerId == m_localPeerId) { + return m_locationProximity.GetNearestLocation(); + } + auto it = m_remotePlayers.find(p_peerId); + if (it != m_remotePlayers.end()) { + return it->second->GetNearestLocation(); + } + return -1; +} + +bool NetworkManager::GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const +{ + if (p_peerId == m_localPeerId) { + LegoPathActor* userActor = UserActor(); + if (userActor && userActor->GetROI()) { + const float* pos = userActor->GetROI()->GetWorldPosition(); + p_x = pos[0]; + p_z = pos[2]; + return true; + } + return false; + } + auto it = m_remotePlayers.find(p_peerId); + if (it != m_remotePlayers.end() && it->second->IsSpawned() && it->second->GetROI()) { + const float* pos = it->second->GetROI()->GetWorldPosition(); + p_x = pos[0]; + p_z = pos[2]; + return true; + } + return false; +} + +bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const +{ + if (p_peerId == 0) { + return false; + } + if (p_peerId == m_localPeerId) { + return true; + } + auto it = m_remotePlayers.find(p_peerId); + if (it == m_remotePlayers.end() || !it->second->IsSpawned() || !it->second->GetROI() || + it->second->GetWorldId() != (int8_t) LegoOmni::e_act1) { + return false; + } + const float* pos = it->second->GetROI()->GetWorldPosition(); + float dx = pos[0] - p_refX; + float dz = pos[2] - p_refZ; + return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ; +} + +bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex) +{ + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (!session) { + return false; + } + + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (!entry) { + return false; + } + + if (entry->location >= 0) { + // Cam anim: all participants must be at the specific location + for (const auto& slot : session->slots) { + if (slot.peerId == 0) { + continue; + } + int16_t loc = GetPeerLocation(slot.peerId); + if (loc >= 0 && loc != entry->location) { + return false; + } + } + return true; + } + + // NPC anim: all participants must be within NPC_ANIM_PROXIMITY of each other + float firstX = 0, firstZ = 0; + bool hasFirst = false; + + for (const auto& slot : session->slots) { + if (slot.peerId == 0) { + continue; + } + + float px, pz; + if (!GetPeerPosition(slot.peerId, px, pz)) { + continue; // Position unknown — don't block + } + + if (!hasFirst) { + firstX = px; + firstZ = pz; + hasFirst = true; + } + else { + float dx = px - firstX; + float dz = pz - firstZ; + if ((dx * dx + dz * dz) > (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { + return false; + } + } + } + + return true; +} + void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) { uint32_t targetPeerId = p_msg.targetPeerId; @@ -911,7 +1707,7 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) it->second->GetCustomizeState(), p_msg.changeType == CHANGE_MOOD ); - if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote()) { + if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote() && !m_scenePlayer.IsPlaying()) { it->second->StopClickAnimation(); MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation( it->second->GetROI(), @@ -951,8 +1747,8 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) p_msg.changeType == CHANGE_MOOD ); - // Only play click animation in 3rd person (not visible in 1st person or multi-part emote) - if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsInMultiPartEmote()) { + // Only play click animation in 3rd person (not during multi-part emote or animation playback) + if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsInMultiPartEmote() && !cam->IsAnimPlaying()) { cam->StopClickAnimation(); MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation(cam->GetDisplayROI(), cam->GetCustomizeState()); @@ -961,3 +1757,241 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) } } } + +// Helper: append a JSON-escaped string value (assumes no control chars in input) +static void JsonAppendString(std::string& p_out, const char* p_str) +{ + p_out += '"'; + p_out += p_str; + p_out += '"'; +} + +static void BuildAnimationJson( + std::string& p_json, + const Animation::EligibilityInfo& p_info, + const AnimInfo* p_animInfo, + uint8_t p_sessionState, + uint16_t p_countdownMs, + bool p_localInSession, + int8_t p_localCharIndex +) +{ + p_json += "{\"animIndex\":"; + p_json += std::to_string(p_info.animIndex); + p_json += ",\"name\":"; + JsonAppendString(p_json, p_animInfo->m_name ? p_animInfo->m_name : ""); + p_json += ",\"objectId\":"; + p_json += std::to_string(p_animInfo->m_objectId); + p_json += ",\"category\":"; + p_json += std::to_string(static_cast(p_info.entry->category)); + p_json += ",\"eligible\":"; + p_json += p_info.eligible ? "true" : "false"; + p_json += ",\"atLocation\":"; + p_json += p_info.atLocation ? "true" : "false"; + p_json += ",\"sessionState\":"; + p_json += std::to_string(p_sessionState); + p_json += ",\"countdownMs\":"; + p_json += std::to_string(p_countdownMs); + p_json += ",\"localInSession\":"; + p_json += p_localInSession ? "true" : "false"; + + // canJoin: local player could fill an unfilled slot (checked via bitmasks) + bool canJoin = false; + if (!p_localInSession && p_sessionState >= 1 && p_localCharIndex >= 0) { + uint64_t localBit = uint64_t(1) << p_localCharIndex; + if ((p_info.entry->performerMask & localBit)) { + // Find this performer's slot index and check if unfilled + uint8_t slotIdx = 0; + for (int8_t bit = 0; bit < p_localCharIndex; bit++) { + if (p_info.entry->performerMask & (uint64_t(1) << bit)) { + slotIdx++; + } + } + if (slotIdx < p_info.slots.size() && !p_info.slots[slotIdx].filled) { + canJoin = true; + } + } + else { + // Check spectator slot (last slot): unfilled and player is eligible + if (!p_info.slots.empty() && !p_info.slots.back().filled && + Animation::Catalog::CanParticipateChar(p_info.entry, p_localCharIndex)) { + canJoin = true; + } + } + } + p_json += ",\"canJoin\":"; + p_json += canJoin ? "true" : "false"; + + p_json += ",\"slots\":["; + for (size_t s = 0; s < p_info.slots.size(); s++) { + const auto& slot = p_info.slots[s]; + if (s > 0) { + p_json += ','; + } + p_json += "{\"names\":["; + for (size_t n = 0; n < slot.names.size(); n++) { + if (n > 0) { + p_json += ','; + } + JsonAppendString(p_json, slot.names[n]); + } + p_json += "],\"filled\":"; + p_json += slot.filled ? "true" : "false"; + p_json += '}'; + } + p_json += "]}"; +} + +void NetworkManager::PushAnimationState() +{ + ThirdPersonCamera::Controller* cam = GetCamera(); + if (!cam || !cam->GetDisplayROI()) { + // Camera unavailable — push idle state so the frontend clears any countdown/session UI + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON); + } + return; + } + + int16_t location = m_locationProximity.GetNearestLocation(); + uint8_t displayActorIndex = cam->GetDisplayActorIndex(); + int8_t localCharIndex = Animation::Catalog::DisplayActorToCharacterIndex(displayActorIndex); + + LegoPathActor* userActor = UserActor(); + if (!userActor || !userActor->GetROI()) { + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON); + } + return; + } + const float* localPos = userActor->GetROI()->GetWorldPosition(); + float localX = localPos[0], localZ = localPos[2]; + + // Build two sets of character indices: + // - locationCharIndices: players at the same location (for cam anims) + // - proximityCharIndices: players within NPC_ANIM_PROXIMITY (for NPC anims) + std::vector locationCharIndices; + std::vector proximityCharIndices; + locationCharIndices.push_back(localCharIndex); + proximityCharIndices.push_back(localCharIndex); + + for (const auto& [peerId, player] : m_remotePlayers) { + if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { + continue; + } + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); + if (player->GetNearestLocation() == location) { + locationCharIndices.push_back(charIdx); + } + // Exact NPC_ANIM_PROXIMITY radius for triggering eligibility + // (tighter than IsPeerNearby's NPC_ANIM_NEARBY_RADIUS_SQ used for session visibility) + const float* rpos = player->GetROI()->GetWorldPosition(); + float dx = rpos[0] - localX; + float dz = rpos[2] - localZ; + if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { + proximityCharIndices.push_back(charIdx); + } + } + + auto eligibility = m_animCoordinator.ComputeEligibility( + location, + locationCharIndices.data(), + static_cast(locationCharIndices.size()), + proximityCharIndices.data(), + static_cast(proximityCharIndices.size()) + ); + + // Build JSON + std::string json; + json.reserve(2048); + json += "{\"location\":"; + json += std::to_string(location); + json += ",\"state\":"; + json += std::to_string(static_cast(m_animCoordinator.GetState())); + json += ",\"currentAnimIndex\":"; + json += std::to_string(m_animCoordinator.GetCurrentAnimIndex()); + json += ",\"pendingInterest\":"; + json += std::to_string(m_localPendingAnimInterest); + json += ",\"animations\":["; + + bool firstAnim = true; + for (size_t i = 0; i < eligibility.size(); i++) { + const auto& info = eligibility[i]; + const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(info.animIndex); + if (!animInfo) { + continue; + } + + if (!firstAnim) { + json += ','; + } + firstAnim = false; + + // Session state: host computes live countdown, clients derive from countdownEndTime + uint8_t sessionState = 0; + uint16_t countdownMs = 0; + if (IsHost()) { + const Animation::AnimSession* hostSession = m_animSessionHost.FindSession(info.animIndex); + if (hostSession) { + sessionState = static_cast(hostSession->state); + countdownMs = Animation::SessionHost::ComputeCountdownMs(*hostSession, SDL_GetTicks()); + } + } + else { + const Animation::SessionView* sv = m_animCoordinator.GetSessionView(info.animIndex); + if (sv) { + sessionState = static_cast(sv->state); + if (sv->state == Animation::CoordinationState::e_countdown && sv->countdownEndTime > 0) { + uint32_t now = SDL_GetTicks(); + countdownMs = (now < sv->countdownEndTime) ? static_cast(sv->countdownEndTime - now) : 0; + } + else { + countdownMs = sv->countdownMs; + } + } + } + + bool localInSession = m_animCoordinator.IsLocalPlayerInSession(info.animIndex); + + // Suppress session display if local player is not in the session and no + // session participant is nearby — prevents stale "Join!" for far-away sessions + if (sessionState > 0 && !localInSession) { + bool anyParticipantNearby = false; + + if (IsHost()) { + const Animation::AnimSession* hs = m_animSessionHost.FindSession(info.animIndex); + if (hs) { + for (const auto& slot : hs->slots) { + if (IsPeerNearby(slot.peerId, localX, localZ)) { + anyParticipantNearby = true; + break; + } + } + } + } + else { + const Animation::SessionView* ssv = m_animCoordinator.GetSessionView(info.animIndex); + if (ssv) { + for (uint8_t s = 0; s < ssv->slotCount && !anyParticipantNearby; s++) { + if (IsPeerNearby(ssv->peerSlots[s], localX, localZ)) { + anyParticipantNearby = true; + } + } + } + } + + if (!anyParticipantNearby) { + sessionState = 0; + countdownMs = 0; + } + } + + BuildAnimationJson(json, info, animInfo, sessionState, countdownMs, localInSession, localCharIndex); + } + + json += "]}"; + + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(json.c_str()); + } +} diff --git a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp index 2f055649..6ef1d5f6 100644 --- a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -64,6 +64,20 @@ void EmscriptenCallbacks::OnConnectionStatusChanged(int p_status) // clang-format on } +void EmscriptenCallbacks::OnAnimationsAvailable(const char* p_json) +{ + // clang-format off + MAIN_THREAD_EM_ASM({ + var canvas = Module.canvas; + if (canvas) { + canvas.dispatchEvent(new CustomEvent('animationsAvailable', { + detail: { json: UTF8ToString($0) } + })); + } + }, p_json); + // clang-format on +} + } // namespace Multiplayer #endif // __EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp index 1b78830b..1ee63978 100644 --- a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -58,6 +58,22 @@ extern "C" } } + EMSCRIPTEN_KEEPALIVE void mp_set_anim_interest(int animIndex) + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestSetAnimInterest(static_cast(animIndex)); + } + } + + EMSCRIPTEN_KEEPALIVE void mp_cancel_anim_interest() + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestCancelAnimInterest(); + } + } + } // extern "C" #endif diff --git a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp index 75059592..6f834b46 100644 --- a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp +++ b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp @@ -52,6 +52,11 @@ void NativeCallbacks::OnConnectionStatusChanged(int p_status) SDL_Log("[Multiplayer] Connection status: %s", statusStr); } +void NativeCallbacks::OnAnimationsAvailable(const char* p_json) +{ + (void) p_json; +} + } // namespace Multiplayer #endif // !__EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index 59e5944a..bba4c15e 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -29,9 +29,9 @@ using Common::WORLD_NOT_VISIBLE; RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex) : m_peerId(p_peerId), m_actorId(p_actorId), m_displayActorIndex(p_displayActorIndex), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f), m_targetVehicleType(VEHICLE_NONE), - m_targetWorldId(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), + m_targetWorldId(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), m_nearestLocation(-1), m_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}), - m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true) + m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true), m_animationLocked(false) { m_displayName[0] = '\0'; const char* displayName = GetDisplayActorName(); @@ -197,6 +197,15 @@ void RemotePlayer::Tick(float p_deltaTime) return; } + // During animation playback, skip transform/animation updates (ScenePlayer drives + // our ROI), but still update the name bubble so it follows the animated position. + if (m_animationLocked) { + if (m_nameBubble) { + m_nameBubble->Update(m_roi); + } + return; + } + UpdateVehicleState(); UpdateTransform(p_deltaTime); diff --git a/extensions/src/siloader.cpp b/extensions/src/siloader.cpp index 4ed087c8..937ecdaf 100644 --- a/extensions/src/siloader.cpp +++ b/extensions/src/siloader.cpp @@ -1,5 +1,6 @@ #include "extensions/siloader.h" +#include "extensions/common/pathutils.h" #include "legovideomanager.h" #include "misc.h" #include "mxdsaction.h" @@ -240,15 +241,11 @@ bool SiLoaderExt::LoadFile(const char* p_file) si::Interleaf si; MxStreamController* controller; - MxString path = MxString(MxOmni::GetHD()) + p_file; - path.MapPathToFilesystem(); - if (si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { - path = MxString(MxOmni::GetCD()) + p_file; - path.MapPathToFilesystem(); - if (si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { - SDL_Log("Could not parse SI file %s", p_file); - return false; - } + MxString path; + if (!Common::ResolveGamePath(p_file, path) || + si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { + SDL_Log("Could not parse SI file %s", p_file); + return false; } if (!(controller = OpenStream(p_file))) { diff --git a/extensions/src/textureloader.cpp b/extensions/src/textureloader.cpp index 7542db33..41550a1b 100644 --- a/extensions/src/textureloader.cpp +++ b/extensions/src/textureloader.cpp @@ -1,5 +1,6 @@ #include "extensions/textureloader.h" +#include "extensions/common/pathutils.h" #include "legovideomanager.h" #include "misc.h" #include "mxdirectx/mxdirect3d.h" @@ -115,16 +116,13 @@ SDL_Surface* TextureLoaderExt::FindTexture(const char* p_name) return nullptr; } - SDL_Surface* surface; const char* texturePath = options["texture loader:texture path"].c_str(); - MxString path = MxString(MxOmni::GetHD()) + texturePath + "/" + p_name + ".bmp"; + MxString relativePath = MxString(texturePath) + "/" + p_name + ".bmp"; - path.MapPathToFilesystem(); - if (!(surface = SDL_LoadBMP(path.GetData()))) { - path = MxString(MxOmni::GetCD()) + texturePath + "/" + p_name + ".bmp"; - path.MapPathToFilesystem(); - surface = SDL_LoadBMP(path.GetData()); + MxString path; + if (!Common::ResolveGamePath(relativePath.GetData(), path)) { + return nullptr; } - return surface; + return SDL_LoadBMP(path.GetData()); } diff --git a/extensions/src/thirdpersoncamera.cpp b/extensions/src/thirdpersoncamera.cpp index 70aac3f1..92e4f7e2 100644 --- a/extensions/src/thirdpersoncamera.cpp +++ b/extensions/src/thirdpersoncamera.cpp @@ -123,7 +123,7 @@ void ThirdPersonCameraExt::OnSDLEvent(SDL_Event* p_event) s_camera->SetLmbForwardEngaged(false); } - if (s_camera->ConsumeAutoDisable()) { + if (s_camera->ConsumeAutoDisable() && !s_camera->IsAnimPlaying()) { s_camera->Disable(/*p_preserveTouch=*/true); if (s_camera->IsLeftButtonHeld()) { s_camera->SetLmbForwardEngaged(true); diff --git a/extensions/src/thirdpersoncamera/controller.cpp b/extensions/src/thirdpersoncamera/controller.cpp index a7a912ad..ad44a164 100644 --- a/extensions/src/thirdpersoncamera/controller.cpp +++ b/extensions/src/thirdpersoncamera/controller.cpp @@ -30,7 +30,9 @@ using namespace Extensions::ThirdPersonCamera; Controller::Controller() : m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true, /*.propSuffix=*/0}), m_enabled(false), - m_active(false), m_pendingWorldTransition(false), m_lmbForwardEngaged(false), m_playerROI(nullptr) + m_active(false), m_pendingWorldTransition(false), m_animPlaying(false), m_animLockDisplay(false), + m_lmbForwardEngaged(false), + m_playerROI(nullptr) { } @@ -51,6 +53,15 @@ void Controller::Disable(bool p_preserveTouch) void Controller::Deactivate() { + // Stop external animation before destroying the display ROI + if (m_animPlaying) { + if (m_animStopCallback) { + m_animStopCallback(); + } + m_animPlaying = false; + m_animStopCallback = nullptr; + } + if (m_active && m_playerROI) { m_playerROI->SetVisibility(FALSE); VideoManager()->Get3DManager()->Remove(*m_playerROI); @@ -201,12 +212,13 @@ void Controller::Tick(float p_deltaTime) } } - if (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled) { + if (!m_animPlaying && (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled)) { m_orbit.ApplyOrbitCamera(); } - // Small vehicle with ride animation - if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) { + // Small vehicle with ride animation (skip when external animation is active — + // the animation controller handles positioning the player and vehicle ROI) + if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE && !m_animPlaying) { m_animator.StopClickAnimation(); if (m_animator.GetRideAnim() && m_animator.GetRideRoiMap()) { LegoPathActor* actor = UserActor(); @@ -232,15 +244,7 @@ void Controller::Tick(float p_deltaTime) float timeInCycle = m_animator.GetAnimTime() - duration * SDL_floorf(m_animator.GetAnimTime() / duration); - LegoTreeNode* root = m_animator.GetRideAnim()->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) timeInCycle, - m_animator.GetRideRoiMap() - ); - } + AnimUtils::ApplyTree(m_animator.GetRideAnim(), transform, (LegoTime) timeInCycle, m_animator.GetRideRoiMap()); } } return; @@ -251,6 +255,17 @@ void Controller::Tick(float p_deltaTime) return; } + // When an external animation is playing, prevent movement. + // If the display ROI is being driven by the animation (performer), skip everything. + // If the local player is spectating, still sync + idle animate. + if (m_animPlaying) { + userActor->SetWorldSpeed(0.0f); + NavController()->SetLinearVel(0.0f); + if (m_animLockDisplay) { + return; + } + } + // Sync display clone position from native ROI if (m_display.GetDisplayROI() && m_display.GetDisplayROI() == m_playerROI) { m_display.SyncTransformFromNative(userActor->GetROI()); @@ -340,6 +355,16 @@ void Controller::OnWorldDisabled(LegoWorld* p_world) if (!p_world) { return; } + + // Stop external animation before destroying the display ROI + if (m_animPlaying) { + if (m_animStopCallback) { + m_animStopCallback(); + } + m_animPlaying = false; + m_animStopCallback = nullptr; + } + m_active = false; m_pendingWorldTransition = false; m_playerROI = nullptr; @@ -366,7 +391,7 @@ MxBool Controller::HandleCameraRelativeMovement( p_newPos, p_newDir, p_deltaTime, - m_animator.IsInMultiPartEmote(), + m_animator.IsInMultiPartEmote() || m_animPlaying, m_input.IsLeftButtonHeld() ); }