diff --git a/CMakeLists.txt b/CMakeLists.txt index f43cd008..edaecd66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -532,9 +532,12 @@ if (ISLE_EXTENSIONS) extensions/src/siloader.cpp extensions/src/textureloader.cpp extensions/src/multiplayer.cpp + extensions/src/multiplayer/animutils.cpp extensions/src/multiplayer/charactercloner.cpp extensions/src/multiplayer/networkmanager.cpp + extensions/src/multiplayer/protocol.cpp extensions/src/multiplayer/remoteplayer.cpp + extensions/src/multiplayer/thirdpersoncamera.cpp extensions/src/multiplayer/worldstatesync.cpp ) if(EMSCRIPTEN) diff --git a/LEGO1/lego/legoomni/src/actors/islepathactor.cpp b/LEGO1/lego/legoomni/src/actors/islepathactor.cpp index df7c3634..2d200ee4 100644 --- a/LEGO1/lego/legoomni/src/actors/islepathactor.cpp +++ b/LEGO1/lego/legoomni/src/actors/islepathactor.cpp @@ -1,6 +1,7 @@ #include "islepathactor.h" #include "3dmanager/lego3dmanager.h" +#include "extensions/multiplayer.h" #include "isle_actions.h" #include "jukebox_actions.h" #include "legoanimationmanager.h" @@ -16,6 +17,8 @@ #include "scripts.h" #include "viewmanager/viewmanager.h" +using namespace Extensions; + DECOMP_SIZE_ASSERT(IslePathActor, 0x160) DECOMP_SIZE_ASSERT(IslePathActor::SpawnLocation, 0x38) @@ -95,6 +98,8 @@ void IslePathActor::Enter() TurnAround(); TransformPointOfView(); } + + Extension::Call(HandleActorEnter, this); } // FUNCTION: LEGO1 0x1001a3f0 @@ -154,6 +159,8 @@ void IslePathActor::Exit() TurnAround(); TransformPointOfView(); ResetViewVelocity(); + + Extension::Call(HandleActorExit, this); } // GLOBAL: LEGO1 0x10102b28 diff --git a/LEGO1/lego/legoomni/src/paths/legopathactor.cpp b/LEGO1/lego/legoomni/src/paths/legopathactor.cpp index a91a51be..a33ca596 100644 --- a/LEGO1/lego/legoomni/src/paths/legopathactor.cpp +++ b/LEGO1/lego/legoomni/src/paths/legopathactor.cpp @@ -1,6 +1,7 @@ #include "legopathactor.h" #include "define.h" +#include "extensions/multiplayer.h" #include "geom/legoorientededge.h" #include "legocachesoundmanager.h" #include "legocameracontroller.h" @@ -20,6 +21,8 @@ #include #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(LegoPathActor, 0x154) DECOMP_SIZE_ASSERT(LegoPathEdgeContainer, 0x3c) @@ -262,6 +265,11 @@ MxS32 LegoPathActor::CalculateTransform(float p_time, Matrix4& p_transform) m_worldSpeed = nav->GetLinearVel(); + MxBool invertDir = Extension::Call(ShouldInvertMovement, this).value_or(FALSE); + if (invertDir) { + dir *= -1.0f; + } + if (nav->CalculateNewPosDir(pos, dir, newPos, newDir, m_boundary->GetUp())) { Mx3DPointFloat newPosCopy; newPosCopy = newPos; @@ -321,6 +329,10 @@ MxS32 LegoPathActor::CalculateTransform(float p_time, Matrix4& p_transform) } } + if (invertDir) { + newDir *= -1.0f; + } + p_transform.SetIdentity(); Vector3 right(p_transform[0]); diff --git a/extensions/include/extensions/extensions.h b/extensions/include/extensions/extensions.h index a1fcb95d..b46fe2a0 100644 --- a/extensions/include/extensions/extensions.h +++ b/extensions/include/extensions/extensions.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace Extensions { @@ -17,14 +18,26 @@ LEGO1_EXPORT void Enable(const char* p_key, std::map p template struct Extension { template - static auto Call(Function&& function, Args&&... args) -> std::optional> + static auto Call(Function&& function, Args&&... args) { + using result_t = std::invoke_result_t; + if constexpr (std::is_void_v) { #ifdef EXTENSIONS - if (T::enabled) { - return std::invoke(std::forward(function), std::forward(args)...); - } + if (T::enabled) { + std::invoke(std::forward(function), std::forward(args)...); + } #endif - return std::nullopt; + } + else { +#ifdef EXTENSIONS + if (T::enabled) { + return std::optional( + std::invoke(std::forward(function), std::forward(args)...) + ); + } +#endif + return std::optional(std::nullopt); + } } }; }; // namespace Extensions diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index 8f9f3958..117b7d47 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -6,7 +6,9 @@ #include #include +class IslePathActor; class LegoEntity; +class LegoPathActor; class LegoWorld; namespace Multiplayer @@ -34,6 +36,10 @@ class MultiplayerExt { static std::string relayUrl; static std::string room; + static void HandleActorEnter(IslePathActor* p_actor); + static void HandleActorExit(IslePathActor* p_actor); + static MxBool ShouldInvertMovement(LegoPathActor* p_actor); + // Returns true if the multiplayer connection was rejected (e.g. room full). static MxBool CheckRejected(); @@ -51,10 +57,16 @@ LEGO1_EXPORT bool IsMultiplayerRejected(); constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; +constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter; +constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit; +constexpr auto ShouldInvertMovement = &MultiplayerExt::ShouldInvertMovement; constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; #else constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; +constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr; +constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr; +constexpr decltype(&MultiplayerExt::ShouldInvertMovement) ShouldInvertMovement = nullptr; constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; #endif diff --git a/extensions/include/extensions/multiplayer/animutils.h b/extensions/include/extensions/multiplayer/animutils.h new file mode 100644 index 00000000..d0b335a0 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animutils.h @@ -0,0 +1,82 @@ +#pragma once + +#include "mxtypes.h" +#include "roi/legoroi.h" + +#include +#include + +class LegoAnim; + +namespace Multiplayer +{ + +namespace AnimUtils +{ + +// Cached ROI map entry for an animation +struct AnimCache { + LegoAnim* anim; + LegoROI** roiMap; + MxU32 roiMapSize; + + AnimCache() : anim(nullptr), roiMap(nullptr), roiMapSize(0) {} + ~AnimCache() + { + if (roiMap) { + delete[] roiMap; + } + } + + AnimCache(const AnimCache&) = delete; + AnimCache& operator=(const AnimCache&) = delete; + AnimCache(AnimCache&& p_other) noexcept + : anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize) + { + p_other.roiMap = nullptr; + p_other.roiMapSize = 0; + p_other.anim = nullptr; + } + AnimCache& operator=(AnimCache&& p_other) noexcept + { + if (this != &p_other) { + if (roiMap) { + delete[] roiMap; + } + anim = p_other.anim; + roiMap = p_other.roiMap; + roiMapSize = p_other.roiMapSize; + p_other.roiMap = nullptr; + p_other.roiMapSize = 0; + p_other.anim = nullptr; + } + return *this; + } +}; + +void BuildROIMap( + LegoAnim* p_anim, + LegoROI* p_rootROI, + LegoROI* p_extraROI, + LegoROI**& p_roiMap, + MxU32& p_roiMapSize +); + +AnimCache* GetOrBuildAnimCache( + std::map& p_cacheMap, + LegoROI* p_roi, + const char* p_animName +); + +inline void EnsureROIMapVisibility(LegoROI** p_roiMap, MxU32 p_roiMapSize) +{ + for (MxU32 i = 1; i < p_roiMapSize; i++) { + if (p_roiMap[i] != nullptr) { + p_roiMap[i]->SetVisibility(TRUE); + } + } +} + +} // namespace AnimUtils + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 1506afc2..df96d8a8 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -4,6 +4,7 @@ #include "extensions/multiplayer/platformcallbacks.h" #include "extensions/multiplayer/protocol.h" #include "extensions/multiplayer/remoteplayer.h" +#include "extensions/multiplayer/thirdpersoncamera.h" #include "extensions/multiplayer/worldstatesync.h" #include "mxcore.h" #include "mxtypes.h" @@ -49,6 +50,8 @@ class NetworkManager : public MxCore { void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); + ThirdPersonCamera& GetThirdPersonCamera() { return m_thirdPersonCamera; } + // Called from multiplayer extension when a plant/building entity is clicked. // Returns TRUE if the mutation should be suppressed locally (non-host). MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType); @@ -72,7 +75,6 @@ class NetworkManager : public MxCore { void RemoveAllRemotePlayers(); void NotifyPlayerCountChanged(); - int8_t DetectLocalVehicleType(); // Serialize and send a fixed-size message via the transport template @@ -81,6 +83,7 @@ class NetworkManager : public MxCore { NetworkTransport* m_transport; PlatformCallbacks* m_callbacks; WorldStateSync m_worldSync; + ThirdPersonCamera m_thirdPersonCamera; std::map> m_remotePlayers; uint32_t m_localPeerId; diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 71035314..c26449f6 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -5,6 +5,8 @@ #include #include +class LegoPathActor; + namespace Multiplayer { @@ -127,31 +129,25 @@ struct EmoteMsg { #pragma pack(pop) -// Walk animation table: index -> CNs name -static const char* const g_walkAnimNames[] = { - "CNs001xx", // 0: Normal (default) - "CNs002xx", // 1: Joyful - "CNs003xx", // 2: Gloomy - "CNs005xx", // 3: Leaning - "CNs006xx", // 4: Scared - "CNs007xx", // 5: Hyper -}; -static const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]); +// Animation and vehicle tables (defined in protocol.cpp) +extern const char* const g_walkAnimNames[]; +extern const int g_walkAnimCount; -// Idle animation table: index -> CNs name -static const char* const g_idleAnimNames[] = { - "CNs008xx", // 0: Sway (default) - "CNs009xx", // 1: Groove - "CNs010xx", // 2: Excited -}; -static const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]); +extern const char* const g_idleAnimNames[]; +extern const int g_idleAnimCount; -// Emote table: index -> CNs name -static const char* const g_emoteAnimNames[] = { - "CNs011xx", // 0: Wave - "CNs012xx", // 1: Hat Tip -}; -static const int g_emoteAnimCount = sizeof(g_emoteAnimNames) / sizeof(g_emoteAnimNames[0]); +extern const char* const g_emoteAnimNames[]; +extern const int g_emoteAnimCount; + +extern const char* const g_vehicleROINames[VEHICLE_COUNT]; +extern const char* const g_rideAnimNames[VEHICLE_COUNT]; +extern const char* const g_rideVehicleROINames[VEHICLE_COUNT]; + +// Returns true if the vehicle type has no ride animation (model swap instead) +bool IsLargeVehicle(int8_t p_vehicleType); + +// Detect the vehicle type of a given actor, or VEHICLE_NONE if not a vehicle +int8_t DetectVehicleType(LegoPathActor* p_actor); // Validate actorId is a playable character (1-5, not brickster) inline bool IsValidActorId(uint8_t p_actorId) diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 6619be03..af1f42dd 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -1,5 +1,6 @@ #pragma once +#include "extensions/multiplayer/animutils.h" #include "extensions/multiplayer/protocol.h" #include "mxtypes.h" @@ -37,53 +38,8 @@ class RemotePlayer { void TriggerEmote(uint8_t p_emoteId); private: - // Cached ROI map entry for an animation - struct AnimCache { - LegoAnim* anim; - LegoROI** roiMap; - MxU32 roiMapSize; + using AnimCache = AnimUtils::AnimCache; - AnimCache() : anim(nullptr), roiMap(nullptr), roiMapSize(0) {} - ~AnimCache() - { - if (roiMap) { - delete[] roiMap; - } - } - - AnimCache(const AnimCache&) = delete; - AnimCache& operator=(const AnimCache&) = delete; - AnimCache(AnimCache&& p_other) noexcept - : anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize) - { - p_other.roiMap = nullptr; - p_other.roiMapSize = 0; - p_other.anim = nullptr; - } - AnimCache& operator=(AnimCache&& p_other) noexcept - { - if (this != &p_other) { - if (roiMap) { - delete[] roiMap; - } - anim = p_other.anim; - roiMap = p_other.roiMap; - roiMapSize = p_other.roiMapSize; - p_other.roiMap = nullptr; - p_other.roiMapSize = 0; - p_other.anim = nullptr; - } - return *this; - } - }; - - void BuildROIMap( - LegoAnim* p_anim, - LegoROI* p_rootROI, - LegoROI* p_extraROI, - LegoROI**& p_roiMap, - MxU32& p_roiMapSize - ); AnimCache* GetOrBuildAnimCache(const char* p_animName); void UpdateTransform(float p_deltaTime); void UpdateAnimation(float p_deltaTime); diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h new file mode 100644 index 00000000..6b5e5277 --- /dev/null +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -0,0 +1,87 @@ +#pragma once + +#include "extensions/multiplayer/animutils.h" +#include "extensions/multiplayer/protocol.h" +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include +#include +#include + +class IslePathActor; +class LegoPathActor; +class LegoROI; +class LegoWorld; +class LegoAnim; + +namespace Multiplayer +{ + +class ThirdPersonCamera { +public: + ThirdPersonCamera(); + + void Enable(); + void Disable(); + bool IsEnabled() const { return m_enabled; } + bool IsActive() const { return m_active; } + + // Core hooks + void OnActorEnter(IslePathActor* p_actor); + void OnActorExit(IslePathActor* p_actor); + + // Called every frame from NetworkManager::Tickle() + void Tick(float p_deltaTime); + + // Animation selection (forwarded from NetworkManager) + void SetWalkAnimId(uint8_t p_id); + void SetIdleAnimId(uint8_t p_id); + void TriggerEmote(uint8_t p_emoteId); + + void OnWorldEnabled(LegoWorld* p_world); + void OnWorldDisabled(LegoWorld* p_world); + +private: + using AnimCache = AnimUtils::AnimCache; + + AnimCache* GetOrBuildAnimCache(const char* p_animName); + void ClearAnimCaches(); + void SetupCamera(LegoPathActor* p_actor); + void BuildRideAnimation(int8_t p_vehicleType); + void ClearRideAnimation(); + void ApplyIdleFrame0(); + void ReinitForCharacter(); + + bool m_enabled; + bool m_active; + LegoROI* m_playerROI; // Borrowed, not owned + + // Walk/idle state (same pattern as RemotePlayer) + uint8_t m_walkAnimId; + uint8_t m_idleAnimId; + AnimCache* m_walkAnimCache; + AnimCache* m_idleAnimCache; + float m_animTime; + float m_idleTime; + float m_idleAnimTime; + bool m_wasMoving; + + // Emote state + AnimCache* m_emoteAnimCache; + float m_emoteTime; + float m_emoteDuration; + bool m_emoteActive; + MxMatrix m_emoteParentTransform; + + // Vehicle ride state + int8_t m_currentVehicleType; + LegoAnim* m_rideAnim; + LegoROI** m_rideRoiMap; + MxU32 m_rideRoiMapSize; + LegoROI* m_rideVehicleROI; + + std::map m_animCacheMap; +}; + +} // namespace Multiplayer diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index 2dd0a782..ed21e4e8 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -4,9 +4,11 @@ #include "extensions/multiplayer/networkmanager.h" #include "extensions/multiplayer/networktransport.h" #include "extensions/multiplayer/protocol.h" +#include "islepathactor.h" #include "legoactor.h" #include "legoentity.h" #include "legogamestate.h" +#include "legopathactor.h" #include "misc.h" #ifdef __EMSCRIPTEN__ @@ -31,10 +33,6 @@ void MultiplayerExt::Initialize() relayUrl = options["multiplayer:relay url"]; room = options["multiplayer:room"]; - if (relayUrl.empty() || room.empty()) { - return; - } - #ifdef __EMSCRIPTEN__ s_transport = new Multiplayer::WebSocketTransport(relayUrl); s_callbacks = new Multiplayer::EmscriptenCallbacks(); @@ -42,7 +40,12 @@ void MultiplayerExt::Initialize() s_networkManager = new Multiplayer::NetworkManager(); s_networkManager->Initialize(s_transport, s_callbacks); - s_networkManager->Connect(room.c_str()); + // Third-person camera enabled by default, toggled via WASM export + s_networkManager->GetThirdPersonCamera().Enable(); + + if (!relayUrl.empty() && !room.empty()) { + s_networkManager->Connect(room.c_str()); + } #endif } @@ -107,6 +110,29 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity) return s_networkManager->HandleEntityMutation(p_entity, changeType); } +void MultiplayerExt::HandleActorEnter(IslePathActor* p_actor) +{ + if (s_networkManager) { + s_networkManager->GetThirdPersonCamera().OnActorEnter(p_actor); + } +} + +void MultiplayerExt::HandleActorExit(IslePathActor* p_actor) +{ + if (s_networkManager) { + s_networkManager->GetThirdPersonCamera().OnActorExit(p_actor); + } +} + +MxBool MultiplayerExt::ShouldInvertMovement(LegoPathActor* p_actor) +{ + if (s_networkManager && UserActor() == p_actor) { + return s_networkManager->GetThirdPersonCamera().IsActive(); + } + + return FALSE; +} + MxBool MultiplayerExt::CheckRejected() { if (s_networkManager && s_networkManager->WasRejected()) { diff --git a/extensions/src/multiplayer/animutils.cpp b/extensions/src/multiplayer/animutils.cpp new file mode 100644 index 00000000..38198520 --- /dev/null +++ b/extensions/src/multiplayer/animutils.cpp @@ -0,0 +1,130 @@ +#include "extensions/multiplayer/animutils.h" + +#include "anim/legoanim.h" +#include "legoanimpresenter.h" +#include "legoworld.h" +#include "misc.h" +#include "misc/legotree.h" +#include "roi/legoroi.h" + +#include + +using namespace Multiplayer; + +// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime +// via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes. +static void AssignROIIndices( + LegoTreeNode* p_node, + LegoROI* p_parentROI, + LegoROI* p_rootROI, + LegoROI* p_extraROI, + MxU32& p_nextIndex, + std::vector& p_entries +) +{ + LegoROI* roi = p_parentROI; + LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData(); + const char* name = data ? data->GetName() : nullptr; + + if (name != nullptr && *name != '-') { + LegoROI* matchedROI = nullptr; + + if (*name == '*' || p_parentROI == nullptr) { + roi = p_rootROI; + matchedROI = p_rootROI; + } + else { + matchedROI = p_parentROI->FindChildROI(name, p_parentROI); + if (matchedROI == nullptr && p_extraROI != nullptr) { + matchedROI = p_extraROI->FindChildROI(name, p_extraROI); + } + } + + if (matchedROI != nullptr) { + data->SetROIIndex(p_nextIndex); + p_entries.push_back(matchedROI); + p_nextIndex++; + } + else { + data->SetROIIndex(0); + } + } + + for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) { + AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries); + } +} + +void AnimUtils::BuildROIMap( + LegoAnim* p_anim, + LegoROI* p_rootROI, + LegoROI* p_extraROI, + LegoROI**& p_roiMap, + MxU32& p_roiMapSize +) +{ + if (!p_anim || !p_rootROI) { + return; + } + + LegoTreeNode* root = p_anim->GetRoot(); + if (!root) { + return; + } + + MxU32 nextIndex = 1; + std::vector entries; + AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries); + + if (entries.empty()) { + return; + } + + // 1-indexed; index 0 reserved as NULL + p_roiMapSize = entries.size() + 1; + p_roiMap = new LegoROI*[p_roiMapSize]; + p_roiMap[0] = nullptr; + for (MxU32 i = 0; i < entries.size(); i++) { + p_roiMap[i + 1] = entries[i]; + } +} + +AnimUtils::AnimCache* AnimUtils::GetOrBuildAnimCache( + std::map& p_cacheMap, + LegoROI* p_roi, + const char* p_animName +) +{ + if (!p_animName || !p_roi) { + return nullptr; + } + + // Check if already cached + auto it = p_cacheMap.find(p_animName); + if (it != p_cacheMap.end()) { + return &it->second; + } + + // Look up the animation presenter in the current world + LegoWorld* world = CurrentWorld(); + if (!world) { + return nullptr; + } + + MxCore* presenter = world->Find("LegoAnimPresenter", p_animName); + if (!presenter) { + return nullptr; + } + + LegoAnim* anim = static_cast(presenter)->GetAnimation(); + if (!anim) { + return nullptr; + } + + // Build and cache + AnimCache& cache = p_cacheMap[p_animName]; + cache.anim = anim; + BuildROIMap(anim, p_roi, nullptr, cache.roiMap, cache.roiMapSize); + + return &cache; +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index d3f06e67..2a8dc59a 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -42,6 +42,8 @@ NetworkManager::~NetworkManager() MxResult NetworkManager::Tickle() { + m_thirdPersonCamera.Tick(0.016f); + if (!m_transport) { return SUCCESS; } @@ -134,6 +136,8 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) m_registered = true; } + m_thirdPersonCamera.OnWorldEnabled(p_world); + if (p_world->GetWorldId() == LegoOmni::e_act1) { m_inIsleWorld = true; m_worldSync.SetInIsleWorld(true); @@ -158,6 +162,8 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) return; } + m_thirdPersonCamera.OnWorldDisabled(p_world); + if (p_world->GetWorldId() == LegoOmni::e_act1) { m_inIsleWorld = false; m_worldSync.SetInIsleWorld(false); @@ -213,9 +219,19 @@ void NetworkManager::BroadcastLocalState() msg.header = {MSG_STATE, m_localPeerId, m_sequence++}; msg.actorId = actorId; msg.worldId = (int8_t) currentWorld->GetWorldId(); - msg.vehicleType = DetectLocalVehicleType(); + msg.vehicleType = DetectVehicleType(userActor); SDL_memcpy(msg.position, pos, sizeof(msg.position)); SDL_memcpy(msg.direction, dir, sizeof(msg.direction)); + + // Third-person camera: ROI direction is opposite to actual movement direction + // (ShouldInvertMovement preserves TurnAround convention). Negate so remote + // players receive the true movement-facing direction. + if (m_thirdPersonCamera.IsActive()) { + msg.direction[0] = -msg.direction[0]; + msg.direction[1] = -msg.direction[1]; + msg.direction[2] = -msg.direction[2]; + } + SDL_memcpy(msg.up, up, sizeof(msg.up)); msg.speed = speed; msg.walkAnimId = m_localWalkAnimId; @@ -407,6 +423,7 @@ void NetworkManager::SetWalkAnimation(uint8_t p_index) { if (p_index < g_walkAnimCount) { m_localWalkAnimId = p_index; + m_thirdPersonCamera.SetWalkAnimId(p_index); } } @@ -414,6 +431,7 @@ void NetworkManager::SetIdleAnimation(uint8_t p_index) { if (p_index < g_idleAnimCount) { m_localIdleAnimId = p_index; + m_thirdPersonCamera.SetIdleAnimId(p_index); } } @@ -423,6 +441,8 @@ void NetworkManager::SendEmote(uint8_t p_emoteId) return; } + m_thirdPersonCamera.TriggerEmote(p_emoteId); + EmoteMsg msg{}; msg.header = {MSG_EMOTE, m_localPeerId, m_sequence++}; msg.emoteId = p_emoteId; @@ -476,31 +496,3 @@ void NetworkManager::NotifyPlayerCountChanged() m_callbacks->OnPlayerCountChanged(count); } -int8_t NetworkManager::DetectLocalVehicleType() -{ - static const struct { - const char* className; - int8_t vehicleType; - } vehicleMap[] = { - {"Helicopter", VEHICLE_HELICOPTER}, - {"Jetski", VEHICLE_JETSKI}, - {"DuneBuggy", VEHICLE_DUNEBUGGY}, - {"Bike", VEHICLE_BIKE}, - {"SkateBoard", VEHICLE_SKATEBOARD}, - {"Motorcycle", VEHICLE_MOTOCYCLE}, - {"TowTrack", VEHICLE_TOWTRACK}, - {"Ambulance", VEHICLE_AMBULANCE}, - }; - - LegoPathActor* actor = UserActor(); - if (!actor) { - return VEHICLE_NONE; - } - - for (const auto& entry : vehicleMap) { - if (actor->IsA(entry.className)) { - return entry.vehicleType; - } - } - return VEHICLE_NONE; -} diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp index f99dc23a..c183049e 100644 --- a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -34,6 +34,20 @@ extern "C" } } + EMSCRIPTEN_KEEPALIVE void mp_toggle_third_person() + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + Multiplayer::ThirdPersonCamera& cam = mgr->GetThirdPersonCamera(); + if (cam.IsEnabled()) { + cam.Disable(); + } + else { + cam.Enable(); + } + } + } + } // extern "C" #endif diff --git a/extensions/src/multiplayer/protocol.cpp b/extensions/src/multiplayer/protocol.cpp new file mode 100644 index 00000000..a6e96870 --- /dev/null +++ b/extensions/src/multiplayer/protocol.cpp @@ -0,0 +1,79 @@ +#include "extensions/multiplayer/protocol.h" + +#include "legopathactor.h" + +#include + +namespace Multiplayer +{ + +const char* const g_walkAnimNames[] = { + "CNs001xx", // 0: Normal (default) + "CNs002xx", // 1: Joyful + "CNs003xx", // 2: Gloomy + "CNs005xx", // 3: Leaning + "CNs006xx", // 4: Scared + "CNs007xx", // 5: Hyper +}; +const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]); + +const char* const g_idleAnimNames[] = { + "CNs008xx", // 0: Sway (default) + "CNs009xx", // 1: Groove + "CNs010xx", // 2: Excited +}; +const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]); + +const char* const g_emoteAnimNames[] = { + "CNs011xx", // 0: Wave + "CNs012xx", // 1: Hat Tip +}; +const int g_emoteAnimCount = sizeof(g_emoteAnimNames) / sizeof(g_emoteAnimNames[0]); + +// Vehicle model names (LOD names). The helicopter is a compound ROI ("copter") +// with no standalone LOD; use its body part instead. +const char* const g_vehicleROINames[VEHICLE_COUNT] = + {"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"}; + +// Ride animation names for small vehicles (NULL = large vehicle, no ride anim) +const char* const g_rideAnimNames[VEHICLE_COUNT] = + {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL}; + +// Vehicle variant ROI names used in ride animations +const char* const g_rideVehicleROINames[VEHICLE_COUNT] = + {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL}; + +bool IsLargeVehicle(int8_t p_vehicleType) +{ + return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL; +} + +int8_t DetectVehicleType(LegoPathActor* p_actor) +{ + static const struct { + const char* className; + int8_t vehicleType; + } vehicleMap[] = { + {"Helicopter", VEHICLE_HELICOPTER}, + {"Jetski", VEHICLE_JETSKI}, + {"DuneBuggy", VEHICLE_DUNEBUGGY}, + {"Bike", VEHICLE_BIKE}, + {"SkateBoard", VEHICLE_SKATEBOARD}, + {"Motorcycle", VEHICLE_MOTOCYCLE}, + {"TowTrack", VEHICLE_TOWTRACK}, + {"Ambulance", VEHICLE_AMBULANCE}, + }; + + if (!p_actor) { + return VEHICLE_NONE; + } + + for (const auto& entry : vehicleMap) { + if (p_actor->IsA(entry.className)) { + return entry.vehicleType; + } + } + return VEHICLE_NONE; +} + +} // namespace Multiplayer diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index 4bdae635..3fc0d623 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -18,24 +18,9 @@ #include #include #include -#include using namespace Multiplayer; -// LOD names for vehicle models. The helicopter is a compound ROI ("copter") -// with no standalone LOD; use its body part instead. -static const char* g_vehicleROINames[VEHICLE_COUNT] = - {"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"}; - -static const char* g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL}; - -static const char* g_rideVehicleROINames[VEHICLE_COUNT] = {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL}; - -static bool IsLargeVehicle(int8_t p_vehicleType) -{ - return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL; -} - RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId) : m_peerId(p_peerId), m_actorId(p_actorId), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f), m_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1), m_lastUpdateTime(SDL_GetTicks()), @@ -213,38 +198,7 @@ void RemotePlayer::SetVisible(bool p_visible) RemotePlayer::AnimCache* RemotePlayer::GetOrBuildAnimCache(const char* p_animName) { - if (!p_animName || !m_roi) { - return nullptr; - } - - // Check if already cached - auto it = m_animCacheMap.find(p_animName); - if (it != m_animCacheMap.end()) { - return &it->second; - } - - // Look up the animation presenter in the current world - LegoWorld* world = CurrentWorld(); - if (!world) { - return nullptr; - } - - MxCore* presenter = world->Find("LegoAnimPresenter", p_animName); - if (!presenter) { - return nullptr; - } - - LegoAnim* anim = static_cast(presenter)->GetAnimation(); - if (!anim) { - return nullptr; - } - - // Build and cache - AnimCache& cache = m_animCacheMap[p_animName]; - cache.anim = anim; - BuildROIMap(anim, m_roi, nullptr, cache.roiMap, cache.roiMapSize); - - return &cache; + return AnimUtils::GetOrBuildAnimCache(m_animCacheMap, m_roi, p_animName); } void RemotePlayer::TriggerEmote(uint8_t p_emoteId) @@ -269,83 +223,6 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId) m_emoteActive = true; } -// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime -// via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes. -static void AssignROIIndices( - LegoTreeNode* p_node, - LegoROI* p_parentROI, - LegoROI* p_rootROI, - LegoROI* p_extraROI, - MxU32& p_nextIndex, - std::vector& p_entries -) -{ - LegoROI* roi = p_parentROI; - LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData(); - const char* name = data ? data->GetName() : nullptr; - - if (name != nullptr && *name != '-') { - LegoROI* matchedROI = nullptr; - - if (*name == '*' || p_parentROI == nullptr) { - roi = p_rootROI; - matchedROI = p_rootROI; - } - else { - matchedROI = p_parentROI->FindChildROI(name, p_parentROI); - if (matchedROI == nullptr && p_extraROI != nullptr) { - matchedROI = p_extraROI->FindChildROI(name, p_extraROI); - } - } - - if (matchedROI != nullptr) { - data->SetROIIndex(p_nextIndex); - p_entries.push_back(matchedROI); - p_nextIndex++; - } - else { - data->SetROIIndex(0); - } - } - - for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) { - AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries); - } -} - -void RemotePlayer::BuildROIMap( - LegoAnim* p_anim, - LegoROI* p_rootROI, - LegoROI* p_extraROI, - LegoROI**& p_roiMap, - MxU32& p_roiMapSize -) -{ - if (!p_anim || !p_rootROI) { - return; - } - - LegoTreeNode* root = p_anim->GetRoot(); - if (!root) { - return; - } - - MxU32 nextIndex = 1; - std::vector entries; - AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries); - - if (entries.empty()) { - return; - } - - // 1-indexed; index 0 reserved as NULL - p_roiMapSize = entries.size() + 1; - p_roiMap = new LegoROI*[p_roiMapSize]; - p_roiMap[0] = nullptr; - for (MxU32 i = 0; i < entries.size(); i++) { - p_roiMap[i + 1] = entries[i]; - } -} void RemotePlayer::UpdateTransform(float p_deltaTime) { @@ -394,18 +271,10 @@ void RemotePlayer::UpdateAnimation(float p_deltaTime) // Ensure visibility of all mapped ROIs if (walkRoiMap) { - for (MxU32 i = 1; i < walkRoiMapSize; i++) { - if (walkRoiMap[i] != nullptr) { - walkRoiMap[i]->SetVisibility(TRUE); - } - } + AnimUtils::EnsureROIMapVisibility(walkRoiMap, walkRoiMapSize); } if (m_idleAnimCache && m_idleAnimCache->roiMap) { - for (MxU32 i = 1; i < m_idleAnimCache->roiMapSize; i++) { - if (m_idleAnimCache->roiMap[i] != nullptr) { - m_idleAnimCache->roiMap[i]->SetVisibility(TRUE); - } - } + AnimUtils::EnsureROIMapVisibility(m_idleAnimCache->roiMap, m_idleAnimCache->roiMapSize); } bool inVehicle = (m_currentVehicleType != VEHICLE_NONE); @@ -569,7 +438,7 @@ void RemotePlayer::EnterVehicle(int8_t p_vehicleType) m_rideVehicleROI->SetName(vehicleVariantName); } - BuildROIMap(m_rideAnim, m_roi, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize); + AnimUtils::BuildROIMap(m_rideAnim, m_roi, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize); } } diff --git a/extensions/src/multiplayer/thirdpersoncamera.cpp b/extensions/src/multiplayer/thirdpersoncamera.cpp new file mode 100644 index 00000000..1ec2dd3e --- /dev/null +++ b/extensions/src/multiplayer/thirdpersoncamera.cpp @@ -0,0 +1,560 @@ +#include "extensions/multiplayer/thirdpersoncamera.h" + +#include "3dmanager/lego3dmanager.h" +#include "anim/legoanim.h" +#include "islepathactor.h" +#include "legoanimpresenter.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 "mxgeometry/mxmatrix.h" +#include "realtime/realtime.h" +#include "roi/legoroi.h" + +#include + +using namespace Multiplayer; + +ThirdPersonCamera::ThirdPersonCamera() + : m_enabled(false), m_active(false), m_playerROI(nullptr), m_walkAnimId(0), m_idleAnimId(0), + m_walkAnimCache(nullptr), m_idleAnimCache(nullptr), m_animTime(0.0f), m_idleTime(0.0f), m_idleAnimTime(0.0f), + m_wasMoving(false), m_emoteAnimCache(nullptr), m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), + m_currentVehicleType(VEHICLE_NONE), m_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), + m_rideVehicleROI(nullptr) +{ +} + +void ThirdPersonCamera::Enable() +{ + m_enabled = true; +} + +void ThirdPersonCamera::Disable() +{ + m_enabled = false; + m_active = false; + m_playerROI = nullptr; + ClearRideAnimation(); + m_animCacheMap.clear(); + ClearAnimCaches(); +} + +void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor) +{ + if (!m_enabled) { + return; + } + + LegoPathActor* userActor = UserActor(); + if (static_cast(p_actor) != userActor) { + return; + } + + LegoROI* newROI = userActor->GetROI(); + if (!newROI) { + return; + } + + // Detect if we're entering a vehicle + int8_t vehicleType = DetectVehicleType(userActor); + + if (vehicleType != VEHICLE_NONE) { + // Large vehicles and helicopter: stay first-person with dashboard. + // Track the vehicle type so OnActorExit can trigger reinit on exit. + if (IsLargeVehicle(vehicleType) || vehicleType == VEHICLE_HELICOPTER) { + // Hide the walking character ROI that we made visible earlier. + // Enter() doesn't call Exit() on the previous actor, so our + // OnActorExit never fires for the walking character. + if (m_playerROI) { + m_playerROI->SetVisibility(FALSE); + VideoManager()->Get3DManager()->Remove(*m_playerROI); + } + m_currentVehicleType = vehicleType; + m_active = false; + return; + } + + // Small vehicle: need the character ROI for ride animations. + if (!m_playerROI) { + return; + } + + m_currentVehicleType = vehicleType; + m_active = true; + + SetupCamera(userActor); + BuildRideAnimation(vehicleType); + return; + } + + // Non-vehicle (walking character) entry + m_playerROI = newROI; + m_currentVehicleType = VEHICLE_NONE; + m_active = true; + + // Make the player model visible (Enter() hid it for first-person) + m_playerROI->SetVisibility(TRUE); + + // SpawnPlayer() removes the ROI from the 3D manager before calling Enter(). + // Re-add it so the character is actually rendered in third-person mode. + VideoManager()->Get3DManager()->Remove(*m_playerROI); + VideoManager()->Get3DManager()->Add(*m_playerROI); + + // Build animation caches + m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); + m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); + + // Reset animation state + m_animTime = 0.0f; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + m_wasMoving = false; + m_emoteActive = false; + + ApplyIdleFrame0(); + + SetupCamera(userActor); +} + +void ThirdPersonCamera::OnActorExit(IslePathActor* p_actor) +{ + if (!m_enabled) { + return; + } + + // The hook fires at the end of Exit(), after UserActor() has been restored + // to the walking character. For vehicle exit, p_actor is the vehicle (not + // UserActor), so we check m_currentVehicleType instead of comparing actors. + if (m_currentVehicleType != VEHICLE_NONE) { + // Exiting a vehicle: reinitialize immediately for the walking character. + ClearRideAnimation(); + ClearAnimCaches(); + m_animCacheMap.clear(); + ReinitForCharacter(); + } + else if (m_active && static_cast(p_actor) == UserActor()) { + // Exiting on foot (e.g., world transition): full teardown. + // Hide the player ROI and remove it from the 3D manager (we added it + // in OnActorEnter so the character would render in third-person). + if (m_playerROI) { + m_playerROI->SetVisibility(FALSE); + VideoManager()->Get3DManager()->Remove(*m_playerROI); + } + ClearRideAnimation(); + ClearAnimCaches(); + m_currentVehicleType = VEHICLE_NONE; + m_playerROI = nullptr; + m_active = false; + } +} + +void ThirdPersonCamera::Tick(float p_deltaTime) +{ + if (!m_active) { + return; + } + + if (!m_playerROI) { + return; + } + + // Small vehicle with ride animation (like RemotePlayer) + if (m_currentVehicleType != VEHICLE_NONE) { + if (m_rideAnim && m_rideRoiMap) { + LegoPathActor* actor = UserActor(); + if (!actor || !actor->GetROI()) { + return; + } + + // Force visibility of ride ROI map entries + AnimUtils::EnsureROIMapVisibility(m_rideRoiMap, m_rideRoiMapSize); + + // Only advance animation time when actually moving + float speed = actor->GetWorldSpeed(); + if (fabsf(speed) > 0.01f) { + m_animTime += p_deltaTime * 2000.0f; + } + + // Use vehicle actor's transform as base (character ROI may be at old position) + MxMatrix transform(actor->GetROI()->GetLocal2World()); + + // Position character ROI at the vehicle so bones render at the right place + m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(transform); + m_playerROI->SetVisibility(TRUE); + + float duration = (float) m_rideAnim->GetDuration(); + if (duration > 0.0f) { + float timeInCycle = m_animTime - duration * floorf(m_animTime / duration); + + LegoTreeNode* root = m_rideAnim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation( + root->GetChild(i), + transform, + (LegoTime) timeInCycle, + m_rideRoiMap + ); + } + } + } + return; + } + + LegoPathActor* userActor = UserActor(); + if (!userActor) { + return; + } + + // Determine the active walk animation and its ROI map + LegoAnim* walkAnim = nullptr; + LegoROI** walkRoiMap = nullptr; + MxU32 walkRoiMapSize = 0; + + if (m_walkAnimCache && m_walkAnimCache->anim && m_walkAnimCache->roiMap) { + walkAnim = m_walkAnimCache->anim; + walkRoiMap = m_walkAnimCache->roiMap; + walkRoiMapSize = m_walkAnimCache->roiMapSize; + } + + // Ensure visibility of all mapped ROIs + if (walkRoiMap) { + AnimUtils::EnsureROIMapVisibility(walkRoiMap, walkRoiMapSize); + } + if (m_idleAnimCache && m_idleAnimCache->roiMap) { + AnimUtils::EnsureROIMapVisibility(m_idleAnimCache->roiMap, m_idleAnimCache->roiMapSize); + } + + float speed = userActor->GetWorldSpeed(); + bool isMoving = fabsf(speed) > 0.01f; + + // Movement interrupts emotes + if (isMoving && m_emoteActive) { + m_emoteActive = false; + m_emoteAnimCache = nullptr; + } + + if (isMoving) { + if (!walkAnim || !walkRoiMap) { + return; + } + + m_animTime += p_deltaTime * 2000.0f; + float duration = (float) walkAnim->GetDuration(); + if (duration > 0.0f) { + float timeInCycle = m_animTime - duration * floorf(m_animTime / duration); + + MxMatrix transform(m_playerROI->GetLocal2World()); + LegoTreeNode* root = walkAnim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, walkRoiMap); + } + } + m_wasMoving = true; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + } + else if (m_emoteActive && m_emoteAnimCache && m_emoteAnimCache->anim && m_emoteAnimCache->roiMap) { + m_emoteTime += p_deltaTime * 1000.0f; + + if (m_emoteTime >= m_emoteDuration) { + m_emoteActive = false; + m_emoteAnimCache = nullptr; + m_wasMoving = false; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + } + else { + // Use the saved clean parent transform to prevent scale + // accumulation (see TriggerEmote for details). + MxMatrix transform(m_emoteParentTransform); + + LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation( + root->GetChild(i), + transform, + (LegoTime) m_emoteTime, + m_emoteAnimCache->roiMap + ); + } + + // Restore the player ROI's transform — the animation's root + // node (ACTOR_01) wrote a scaled value into it. + m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(m_emoteParentTransform); + } + } + else if (m_idleAnimCache && m_idleAnimCache->anim && m_idleAnimCache->roiMap) { + if (m_wasMoving) { + m_wasMoving = false; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + } + + m_idleTime += p_deltaTime; + + if (m_idleTime >= 2.5f) { + m_idleAnimTime += p_deltaTime * 1000.0f; + } + + float duration = (float) m_idleAnimCache->anim->GetDuration(); + if (duration > 0.0f) { + float timeInCycle = m_idleAnimTime - duration * floorf(m_idleAnimTime / duration); + + MxMatrix transform(m_playerROI->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 + ); + } + } + } +} + +void ThirdPersonCamera::SetWalkAnimId(uint8_t p_id) +{ + if (p_id >= g_walkAnimCount) { + return; + } + + if (p_id != m_walkAnimId) { + m_walkAnimId = p_id; + if (m_active) { + m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); + } + } +} + +void ThirdPersonCamera::SetIdleAnimId(uint8_t p_id) +{ + if (p_id >= g_idleAnimCount) { + return; + } + + if (p_id != m_idleAnimId) { + m_idleAnimId = p_id; + if (m_active) { + m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); + } + } +} + +void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId) +{ + if (p_emoteId >= g_emoteAnimCount || !m_active) { + return; + } + + LegoPathActor* userActor = UserActor(); + if (!userActor || fabsf(userActor->GetWorldSpeed()) > 0.01f) { + return; + } + + AnimCache* cache = GetOrBuildAnimCache(g_emoteAnimNames[p_emoteId]); + if (!cache || !cache->anim) { + return; + } + + m_emoteAnimCache = cache; + m_emoteTime = 0.0f; + m_emoteDuration = (float) cache->anim->GetDuration(); + m_emoteActive = true; + + // Save the clean parent transform before the emote starts. + // The emote animation's root node (ACTOR_01) maps to the player ROI, + // so ApplyAnimationTransformation writes a scaled transform into + // m_playerROI->m_local2world each frame. When the character is + // stationary the engine's CalculateTransform does not run, so the ROI + // is never reset — causing the scale to compound across frames. + // Using the saved clean transform as parent prevents this feedback. + m_emoteParentTransform = m_playerROI->GetLocal2World(); +} + +void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world) +{ + if (!m_enabled || !p_world) { + return; + } + + // Clear stale caches (animation presenters may have been recreated) + m_animCacheMap.clear(); + ClearAnimCaches(); + + ReinitForCharacter(); +} + +void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world) +{ + if (!p_world) { + return; + } + + m_active = false; + m_playerROI = nullptr; + ClearRideAnimation(); + m_animCacheMap.clear(); + ClearAnimCaches(); +} + +ThirdPersonCamera::AnimCache* ThirdPersonCamera::GetOrBuildAnimCache(const char* p_animName) +{ + return AnimUtils::GetOrBuildAnimCache(m_animCacheMap, m_playerROI, p_animName); +} + +void ThirdPersonCamera::ClearAnimCaches() +{ + m_walkAnimCache = nullptr; + m_idleAnimCache = nullptr; + m_emoteAnimCache = nullptr; + m_emoteActive = false; +} + +void ThirdPersonCamera::SetupCamera(LegoPathActor* p_actor) +{ + LegoWorld* world = CurrentWorld(); + if (!world || !world->GetCameraController()) { + return; + } + + // After Enter()'s TurnAround, the ROI direction is negated. + // The mesh faces -z (local) = +path_forward (correct visual facing). + // +z in ROI-local is the negated direction, i.e. behind the visual model. + // Movement inversion is handled by ShouldInvertMovement in CalculateTransform. + Mx3DPointFloat at(0.0f, 2.5f, 3.0f); + Mx3DPointFloat dir(0.0f, -0.3f, -1.0f); + Mx3DPointFloat up(0.0f, 1.0f, 0.0f); + + world->GetCameraController()->SetWorldTransform(at, dir, up); + p_actor->TransformPointOfView(); +} + +void ThirdPersonCamera::BuildRideAnimation(int8_t p_vehicleType) +{ + if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) { + return; + } + + const char* rideAnimName = g_rideAnimNames[p_vehicleType]; + const char* vehicleVariantName = g_rideVehicleROINames[p_vehicleType]; + if (!rideAnimName || !vehicleVariantName) { + return; + } + + LegoWorld* world = CurrentWorld(); + if (!world) { + return; + } + + MxCore* presenter = world->Find("LegoAnimPresenter", rideAnimName); + if (!presenter) { + return; + } + + m_rideAnim = static_cast(presenter)->GetAnimation(); + if (!m_rideAnim) { + return; + } + + // Create variant ROI from base vehicle name, rename for anim tree matching + const char* baseName = g_vehicleROINames[p_vehicleType]; + m_rideVehicleROI = CharacterManager()->CreateAutoROI("tp_vehicle", baseName, FALSE); + if (m_rideVehicleROI) { + m_rideVehicleROI->SetName(vehicleVariantName); + } + + AnimUtils::BuildROIMap(m_rideAnim, m_playerROI, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize); + m_animTime = 0.0f; +} + +void ThirdPersonCamera::ClearRideAnimation() +{ + if (m_rideRoiMap) { + delete[] m_rideRoiMap; + m_rideRoiMap = nullptr; + m_rideRoiMapSize = 0; + } + if (m_rideVehicleROI) { + VideoManager()->Get3DManager()->Remove(*m_rideVehicleROI); + CharacterManager()->ReleaseAutoROI(m_rideVehicleROI); + m_rideVehicleROI = nullptr; + } + m_rideAnim = nullptr; + m_currentVehicleType = VEHICLE_NONE; +} + +void ThirdPersonCamera::ApplyIdleFrame0() +{ + if (!m_playerROI || !m_idleAnimCache || !m_idleAnimCache->anim || !m_idleAnimCache->roiMap) { + return; + } + + MxMatrix transform(m_playerROI->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); + } +} + +void ThirdPersonCamera::ReinitForCharacter() +{ + LegoPathActor* userActor = UserActor(); + if (!userActor) { + m_active = false; + return; + } + + LegoROI* roi = userActor->GetROI(); + if (!roi) { + m_active = false; + return; + } + + int8_t vehicleType = DetectVehicleType(userActor); + + // Large vehicles and helicopter: stay first-person + if (vehicleType == VEHICLE_HELICOPTER || (vehicleType != VEHICLE_NONE && IsLargeVehicle(vehicleType))) { + m_active = false; + return; + } + + m_currentVehicleType = vehicleType; + + if (vehicleType != VEHICLE_NONE) { + if (!m_playerROI) { + m_active = false; + return; + } + m_active = true; + SetupCamera(userActor); + BuildRideAnimation(vehicleType); + return; + } + + // Reinitializing for walking character + m_playerROI = roi; + m_playerROI->SetVisibility(TRUE); + + // Ensure the ROI is in the 3D manager so it gets rendered + VideoManager()->Get3DManager()->Remove(*m_playerROI); + VideoManager()->Get3DManager()->Add(*m_playerROI); + + m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); + m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); + + m_animTime = 0.0f; + m_idleTime = 0.0f; + m_idleAnimTime = 0.0f; + m_wasMoving = false; + m_emoteActive = false; + m_active = true; + + ApplyIdleFrame0(); + SetupCamera(userActor); +}