diff --git a/extensions/include/extensions/multiplayer/characteranimator.h b/extensions/include/extensions/multiplayer/characteranimator.h index 1cfe2012..97c7f56b 100644 --- a/extensions/include/extensions/multiplayer/characteranimator.h +++ b/extensions/include/extensions/multiplayer/characteranimator.h @@ -8,7 +8,9 @@ #include #include #include +#include +class LegoCacheSound; class LegoROI; class LegoAnim; @@ -48,6 +50,11 @@ class CharacterAnimator { void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_clickAnimObjectId = p_clickAnimObjectId; } void StopClickAnimation(); + // Stop all sounds that were played against the character ROI. + // Must be called before the ROI is destroyed to prevent use-after-free + // in the sound system's 3D position update. + void StopROISounds(); + // Vehicle ride animation void BuildRideAnimation(int8_t p_vehicleType, LegoROI* p_playerROI, uint32_t p_vehicleSuffix); void ClearRideAnimation(); @@ -77,7 +84,10 @@ class CharacterAnimator { // Multi-part emote state. Returns true when the player is in any phase of a multi-part // emote (playing phase 1, frozen at last frame, or playing phase 2). Movement is blocked. - bool IsInMultiPartEmote() const { return m_frozenEmoteId >= 0 || (m_emoteActive && IsMultiPartEmote(m_currentEmoteId)); } + bool IsInMultiPartEmote() const + { + return m_frozenEmoteId >= 0 || (m_emoteActive && IsMultiPartEmote(m_currentEmoteId)); + } int8_t GetFrozenEmoteId() const { return m_frozenEmoteId; } void SetFrozenEmoteId(int8_t p_emoteId, LegoROI* p_roi); @@ -91,6 +101,7 @@ class CharacterAnimator { AnimCache* GetOrBuildAnimCache(LegoROI* p_roi, const char* p_animName); void ClearFrozenState(); + void PlayROISound(const char* p_key, LegoROI* p_roi); CharacterAnimatorConfig m_config; @@ -121,6 +132,10 @@ class CharacterAnimator { // Click animation tracking (0 = none) MxU32 m_clickAnimObjectId; + // Sounds played against the character ROI, tracked so they can be + // stopped before the ROI is destroyed. + std::vector m_ROISounds; + // ROI map cache: animation name -> cached ROI map std::map m_animCacheMap; diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 37429fad..42bf1b5e 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -65,6 +65,7 @@ class NetworkManager : public MxCore { void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); } void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.store(true, std::memory_order_relaxed); } + bool IsInIsleWorld() const { return m_inIsleWorld; } bool GetShowNameBubbles() const { return m_showNameBubbles; } RemotePlayer* FindPlayerByROI(LegoROI* roi) const; @@ -78,6 +79,10 @@ class NetworkManager : public MxCore { ThirdPersonCamera& GetThirdPersonCamera() { return m_thirdPersonCamera; } + void NotifyThirdPersonChanged(bool p_enabled); + void NotifyNameBubblesChanged(bool p_enabled); + void NotifyAllowCustomizeChanged(bool p_enabled); + // 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); diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h index d9c3663a..5b8753e9 100644 --- a/extensions/include/extensions/multiplayer/platformcallbacks.h +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -10,6 +10,15 @@ class PlatformCallbacks { // Called when the visible player count changes (joins, leaves, world transitions). // p_count = players visible in current world, or -1 if not in a multiplayer world. virtual void OnPlayerCountChanged(int p_count) = 0; + + // Called when the third-person camera mode changes (toggle or auto-switch). + virtual void OnThirdPersonChanged(bool p_enabled) = 0; + + // Called when name bubbles visibility changes. + virtual void OnNameBubblesChanged(bool p_enabled) = 0; + + // Called when the allow-customization setting changes. + virtual void OnAllowCustomizeChanged(bool p_enabled) = 0; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h index 2798b196..5d3d2eca 100644 --- a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -10,6 +10,9 @@ namespace Multiplayer class EmscriptenCallbacks : public PlatformCallbacks { public: void OnPlayerCountChanged(int p_count) override; + void OnThirdPersonChanged(bool p_enabled) override; + void OnNameBubblesChanged(bool p_enabled) override; + void OnAllowCustomizeChanged(bool p_enabled) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h index 7de796cb..5f8fa21d 100644 --- a/extensions/include/extensions/multiplayer/thirdpersoncamera.h +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -69,12 +69,21 @@ class ThirdPersonCamera { // Free camera input handling void HandleSDLEvent(SDL_Event* p_event); + // Auto-switch flags (set by HandleSDLEvent, consumed by caller) + bool ConsumeAutoDisable(); + bool ConsumeAutoEnable(); + + float GetOrbitDistance() const { return m_orbitDistance; } + void SetOrbitDistance(float p_distance) { m_orbitDistance = p_distance; } + void ResetTouchState() { m_touch = {}; } + // Finger-claiming API for split-screen touch zones (left=movement, right=camera) bool TryClaimFinger(const SDL_TouchFingerEvent& event); bool TryReleaseFinger(SDL_FingerID id); bool IsFingerTracked(SDL_FingerID id) const; static constexpr float CAMERA_ZONE_X = 0.5f; + static constexpr float MIN_DISTANCE = 1.5f; private: // Orbit camera helpers @@ -116,7 +125,7 @@ class ThirdPersonCamera { // Orbit camera state float m_orbitPitch; float m_orbitDistance; - float m_absoluteYaw; // Camera yaw in world space (decoupled from player facing) + float m_absoluteYaw; // Camera yaw in world space (decoupled from player facing) float m_smoothedSpeed; // Extension-managed velocity for smooth acceleration/deceleration // Touch gesture tracking @@ -133,8 +142,10 @@ class ThirdPersonCamera { static constexpr float ORBIT_TARGET_HEIGHT = 1.5f; static constexpr float MIN_PITCH = 0.05f; static constexpr float MAX_PITCH = 1.4f; - static constexpr float MIN_DISTANCE = 1.5f; static constexpr float MAX_DISTANCE = 15.0f; + + bool m_wantsAutoDisable; + bool m_wantsAutoEnable; }; } // namespace Multiplayer diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index a9908bad..1cbe2e4b 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -295,8 +295,25 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name) void MultiplayerExt::HandleSDLEvent(SDL_Event* p_event) { - if (s_networkManager && s_networkManager->GetThirdPersonCamera().IsActive()) { - s_networkManager->GetThirdPersonCamera().HandleSDLEvent(p_event); + if (!s_networkManager || !s_networkManager->IsInIsleWorld()) { + return; + } + + Multiplayer::ThirdPersonCamera& camera = s_networkManager->GetThirdPersonCamera(); + + camera.HandleSDLEvent(p_event); + + // Auto-switch 3rd → 1st: zoom-in past minimum distance + if (camera.ConsumeAutoDisable()) { + camera.Disable(); + s_networkManager->NotifyThirdPersonChanged(false); + } + // Auto-switch 1st → 3rd: zoom-out from 1st person + else if (camera.ConsumeAutoEnable()) { + camera.ResetTouchState(); + camera.SetOrbitDistance(Multiplayer::ThirdPersonCamera::MIN_DISTANCE); + camera.Enable(); + s_networkManager->NotifyThirdPersonChanged(true); } } diff --git a/extensions/src/multiplayer/characteranimator.cpp b/extensions/src/multiplayer/characteranimator.cpp index 8f68b631..203b1e45 100644 --- a/extensions/src/multiplayer/characteranimator.cpp +++ b/extensions/src/multiplayer/characteranimator.cpp @@ -6,6 +6,7 @@ #include "extensions/multiplayer/namebubblerenderer.h" #include "legoanimpresenter.h" #include "legocachesoundmanager.h" +#include "legocachsound.h" #include "legocharactermanager.h" #include "legosoundmanager.h" #include "legovideomanager.h" @@ -74,10 +75,10 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) bool inVehicle = (m_currentVehicleType != VEHICLE_NONE); bool isMoving = inVehicle || p_isMoving; - // Movement interrupts click animations and emotes (but not multi-part emotes) + // Movement interrupts click animations and emotes (but not frozen multi-part emotes) if (isMoving && m_frozenEmoteId < 0) { StopClickAnimation(); - if (m_emoteActive && !IsMultiPartEmote(m_currentEmoteId)) { + if (m_emoteActive) { m_emoteActive = false; m_emoteAnimCache = nullptr; } @@ -254,7 +255,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i const char* sound = g_emoteEntries[p_emoteId].phases[1].sound; if (sound) { - SoundManager()->GetCacheSoundManager()->Play(sound, p_roi->GetName(), FALSE); + PlayROISound(sound, p_roi); } if (m_config.saveEmoteTransform) { @@ -290,7 +291,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i const char* sound = g_emoteEntries[p_emoteId].phases[0].sound; if (sound) { - SoundManager()->GetCacheSoundManager()->Play(sound, p_roi->GetName(), FALSE); + PlayROISound(sound, p_roi); } // Save clean transform to prevent scale accumulation during emote @@ -307,6 +308,23 @@ void CharacterAnimator::StopClickAnimation() } } +void CharacterAnimator::PlayROISound(const char* p_key, LegoROI* p_roi) +{ + LegoCacheSound* sound = SoundManager()->GetCacheSoundManager()->Play(p_key, p_roi->GetName(), FALSE); + if (sound) { + m_ROISounds.push_back(sound); + } +} + +void CharacterAnimator::StopROISounds() +{ + LegoCacheSoundManager* mgr = SoundManager()->GetCacheSoundManager(); + for (LegoCacheSound* sound : m_ROISounds) { + mgr->Stop(sound); + } + m_ROISounds.clear(); +} + void CharacterAnimator::BuildRideAnimation(int8_t p_vehicleType, LegoROI* p_playerROI, uint32_t p_vehicleSuffix) { if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) { @@ -410,6 +428,7 @@ void CharacterAnimator::ClearAnimCaches() m_idleAnimCache = nullptr; m_emoteAnimCache = nullptr; m_emoteActive = false; + StopROISounds(); ClearFrozenState(); } diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 8aa397dc..7a364635 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -155,9 +155,8 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) return; } - m_thirdPersonCamera.OnWorldEnabled(p_world); - if (p_world->GetWorldId() == LegoOmni::e_act1) { + m_thirdPersonCamera.OnWorldEnabled(p_world); m_inIsleWorld = true; m_worldSync.SetInIsleWorld(true); @@ -188,9 +187,8 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) return; } - m_thirdPersonCamera.OnWorldDisabled(p_world); - if (p_world->GetWorldId() == LegoOmni::e_act1) { + m_thirdPersonCamera.OnWorldDisabled(p_world); m_inIsleWorld = false; m_worldSync.SetInIsleWorld(false); for (auto& [peerId, player] : m_remotePlayers) { @@ -247,6 +245,7 @@ void NetworkManager::ProcessPendingRequests() else { m_thirdPersonCamera.Enable(); } + NotifyThirdPersonChanged(m_thirdPersonCamera.IsEnabled()); } int walkAnim = m_pendingWalkAnim.exchange(-1, std::memory_order_relaxed); @@ -266,6 +265,7 @@ void NetworkManager::ProcessPendingRequests() if (m_pendingToggleAllowCustomize.exchange(false, std::memory_order_relaxed)) { m_localAllowRemoteCustomize = !m_localAllowRemoteCustomize; + NotifyAllowCustomizeChanged(m_localAllowRemoteCustomize); } if (m_pendingToggleNameBubbles.exchange(false, std::memory_order_relaxed)) { @@ -274,6 +274,7 @@ void NetworkManager::ProcessPendingRequests() player->SetNameBubbleVisible(m_showNameBubbles); } m_thirdPersonCamera.SetNameBubbleVisible(m_showNameBubbles); + NotifyNameBubblesChanged(m_showNameBubbles); } } @@ -651,6 +652,33 @@ void NetworkManager::NotifyPlayerCountChanged() m_callbacks->OnPlayerCountChanged(count); } +void NetworkManager::NotifyThirdPersonChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnThirdPersonChanged(p_enabled); +} + +void NetworkManager::NotifyNameBubblesChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnNameBubblesChanged(p_enabled); +} + +void NetworkManager::NotifyAllowCustomizeChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnAllowCustomizeChanged(p_enabled); +} + RemotePlayer* NetworkManager::FindPlayerByROI(LegoROI* roi) const { auto it = m_roiToPlayer.find(roi); diff --git a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp index f7a891fc..81f172b3 100644 --- a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -21,6 +21,35 @@ void EmscriptenCallbacks::OnPlayerCountChanged(int p_count) // clang-format on } +// clang-format off +static void DispatchBoolEvent(const char* p_name, bool p_value) +{ + MAIN_THREAD_EM_ASM({ + var canvas = Module.canvas; + if (canvas) { + canvas.dispatchEvent(new CustomEvent(UTF8ToString($0), { + detail: { enabled: !!$1 } + })); + } + }, p_name, p_value ? 1 : 0); +} +// clang-format on + +void EmscriptenCallbacks::OnThirdPersonChanged(bool p_enabled) +{ + DispatchBoolEvent("thirdPersonChanged", p_enabled); +} + +void EmscriptenCallbacks::OnNameBubblesChanged(bool p_enabled) +{ + DispatchBoolEvent("nameBubblesChanged", p_enabled); +} + +void EmscriptenCallbacks::OnAllowCustomizeChanged(bool p_enabled) +{ + DispatchBoolEvent("allowCustomizeChanged", p_enabled); +} + } // namespace Multiplayer #endif // __EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index e35098a9..2798932e 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -192,7 +192,7 @@ void RemotePlayer::Tick(float p_deltaTime) UpdateTransform(p_deltaTime); bool isMoving = m_targetSpeed > 0.01f; - if (m_animator.IsInMultiPartEmote()) { + if (m_animator.GetFrozenEmoteId() >= 0) { isMoving = false; } m_animator.Tick(p_deltaTime, m_roi, isMoving); @@ -254,7 +254,7 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId) } bool isMoving = m_targetSpeed > 0.01f; - if (m_animator.IsInMultiPartEmote()) { + if (m_animator.GetFrozenEmoteId() >= 0) { isMoving = false; } m_animator.TriggerEmote(p_emoteId, m_roi, isMoving); diff --git a/extensions/src/multiplayer/thirdpersoncamera.cpp b/extensions/src/multiplayer/thirdpersoncamera.cpp index 64dffe5b..2bc2acc8 100644 --- a/extensions/src/multiplayer/thirdpersoncamera.cpp +++ b/extensions/src/multiplayer/thirdpersoncamera.cpp @@ -21,6 +21,7 @@ #include "roi/legoroi.h" #include +#include using namespace Multiplayer; @@ -41,8 +42,8 @@ ThirdPersonCamera::ThirdPersonCamera() : m_enabled(false), m_active(false), m_pendingWorldTransition(false), m_playerROI(nullptr), m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(nullptr), m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true}), m_showNameBubble(true), - m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE), - m_absoluteYaw(DEFAULT_ORBIT_YAW), m_smoothedSpeed(0.0f), m_touch{} + m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE), m_absoluteYaw(DEFAULT_ORBIT_YAW), + m_smoothedSpeed(0.0f), m_touch{}, m_wantsAutoDisable(false), m_wantsAutoEnable(false) { SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName)); } @@ -78,6 +79,7 @@ void ThirdPersonCamera::Disable() m_active = false; m_pendingWorldTransition = false; DestroyNameBubble(); + m_animator.StopROISounds(); DestroyDisplayClone(); m_animator.ClearRideAnimation(); m_animator.ClearAll(); @@ -396,6 +398,7 @@ void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world) m_pendingWorldTransition = false; m_playerROI = nullptr; DestroyNameBubble(); + m_animator.StopROISounds(); DestroyDisplayClone(); m_animator.ClearRideAnimation(); m_animator.ClearAll(); @@ -814,15 +817,38 @@ bool ThirdPersonCamera::IsFingerTracked(SDL_FingerID id) const return false; } +bool ThirdPersonCamera::ConsumeAutoDisable() +{ + return std::exchange(m_wantsAutoDisable, false); +} + +bool ThirdPersonCamera::ConsumeAutoEnable() +{ + return std::exchange(m_wantsAutoEnable, false); +} + void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) { switch (p_event->type) { case SDL_EVENT_MOUSE_WHEEL: + if (!m_active) { + if (p_event->wheel.y < 0) { + m_wantsAutoEnable = true; + } + break; + } + if (m_orbitDistance <= MIN_DISTANCE && p_event->wheel.y > 0) { + m_wantsAutoDisable = true; + break; + } m_orbitDistance -= p_event->wheel.y * 0.5f; ClampDistance(); break; case SDL_EVENT_MOUSE_MOTION: + if (!m_active) { + break; + } if (p_event->motion.state & SDL_BUTTON_RMASK) { m_absoluteYaw -= p_event->motion.xrel * 0.005f; m_orbitPitch += p_event->motion.yrel * 0.005f; @@ -832,6 +858,9 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: { + if (!m_active) { + break; + } SDL_Window* window = SDL_GetWindowFromID(p_event->button.windowID); if (window) { SDL_SetWindowRelativeMouseMode(window, SDL_GetMouseState(NULL, NULL) & SDL_BUTTON_RMASK); @@ -842,8 +871,7 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) case SDL_EVENT_FINGER_DOWN: { // Finger may already be claimed via TryClaimFinger (called from HandleTouchInput). // Only register if not already tracked and in the camera zone. - if (!IsFingerTracked(p_event->tfinger.fingerID) && m_touch.count < 2 && - p_event->tfinger.x >= CAMERA_ZONE_X) { + if (!IsFingerTracked(p_event->tfinger.fingerID) && m_touch.count < 2 && p_event->tfinger.x >= CAMERA_ZONE_X) { int idx = m_touch.count; m_touch.id[idx] = p_event->tfinger.fingerID; m_touch.x[idx] = p_event->tfinger.x; @@ -861,6 +889,9 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) case SDL_EVENT_FINGER_MOTION: { if (m_touch.count == 1) { + if (!m_active) { + break; + } // Single-finger drag: apply yaw/pitch rotation if (m_touch.id[0] == p_event->tfinger.fingerID) { float oldX = m_touch.x[0]; @@ -900,12 +931,29 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) if (m_touch.initialPinchDist > 0.001f) { float pinchDelta = m_touch.initialPinchDist - newDist; + + if (!m_active) { + // Pinch together (zoom out) from 1st person → auto-enable 3rd person + if (pinchDelta > 0) { + m_wantsAutoEnable = true; + } + m_touch.initialPinchDist = newDist; + break; + } + + // Spread apart (zoom in) past min distance → auto-disable to 1st person + if (m_orbitDistance <= MIN_DISTANCE && pinchDelta < 0) { + m_wantsAutoDisable = true; + m_touch.initialPinchDist = newDist; + break; + } + m_orbitDistance += pinchDelta * 15.0f; ClampDistance(); m_touch.initialPinchDist = newDist; } - // Two-finger drag for orbit + // Two-finger drag for orbit (only when active) float moveX = m_touch.x[idx] - oldX; float moveY = m_touch.y[idx] - oldY; m_absoluteYaw -= moveX * 2.0f;