From 05716eb94f4f691bbefd3817b2a5f11ec0777f98 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 28 Mar 2026 13:26:13 -0700 Subject: [PATCH] WIP --- LEGO1/lego/legoomni/include/legocachsound.h | 1 + LEGO1/lego/legoomni/src/actors/ambulance.cpp | 4 + LEGO1/lego/legoomni/src/actors/bike.cpp | 4 + LEGO1/lego/legoomni/src/actors/dunebuggy.cpp | 4 + LEGO1/lego/legoomni/src/actors/towtrack.cpp | 4 + .../include/extensions/common/animutils.h | 8 ++ extensions/include/extensions/multiplayer.h | 3 + .../extensions/multiplayer/animation/loader.h | 5 + .../extensions/multiplayer/networkmanager.h | 11 ++ .../include/extensions/multiplayer/protocol.h | 45 ++++--- .../extensions/multiplayer/remoteplayer.h | 3 + extensions/src/common/animutils.cpp | 57 +++++++++ extensions/src/multiplayer.cpp | 27 ++++ .../src/multiplayer/animation/loader.cpp | 46 +++++++ extensions/src/multiplayer/networkmanager.cpp | 118 +++++++++++++++++- extensions/src/multiplayer/remoteplayer.cpp | 41 +++++- 16 files changed, 356 insertions(+), 25 deletions(-) diff --git a/LEGO1/lego/legoomni/include/legocachsound.h b/LEGO1/lego/legoomni/include/legocachsound.h index 4ea3739e..c563fc19 100644 --- a/LEGO1/lego/legoomni/include/legocachsound.h +++ b/LEGO1/lego/legoomni/include/legocachsound.h @@ -60,6 +60,7 @@ class LegoCacheSound : public MxCore { private: friend class Multiplayer::Animation::AudioPlayer; + friend class Multiplayer::NetworkManager; void Init(); void CopyData(MxU8* p_data, MxU32 p_dataSize); diff --git a/LEGO1/lego/legoomni/src/actors/ambulance.cpp b/LEGO1/lego/legoomni/src/actors/ambulance.cpp index 40bb3142..3c9adb14 100644 --- a/LEGO1/lego/legoomni/src/actors/ambulance.cpp +++ b/LEGO1/lego/legoomni/src/actors/ambulance.cpp @@ -1,6 +1,7 @@ #include "ambulance.h" #include "decomp.h" +#include "extensions/multiplayer.h" #include "isle.h" #include "isle_actions.h" #include "jukebox_actions.h" @@ -26,6 +27,8 @@ #include #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(Ambulance, 0x184) DECOMP_SIZE_ASSERT(AmbulanceMissionState, 0x24) @@ -458,6 +461,7 @@ MxLong Ambulance::HandleControl(LegoControlManagerNotificationParam& p_param) MxSoundPresenter* presenter = (MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "AmbulanceHorn_Sound"); presenter->Enable(p_param.m_enabledChild); + Extension::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/bike.cpp b/LEGO1/lego/legoomni/src/actors/bike.cpp index e3414254..a4f7be85 100644 --- a/LEGO1/lego/legoomni/src/actors/bike.cpp +++ b/LEGO1/lego/legoomni/src/actors/bike.cpp @@ -1,5 +1,6 @@ #include "bike.h" +#include "extensions/multiplayer.h" #include "isle.h" #include "isle_actions.h" #include "jukebox_actions.h" @@ -13,6 +14,8 @@ #include "mxtransitionmanager.h" #include "scripts.h" +using namespace Extensions; + DECOMP_SIZE_ASSERT(Bike, 0x164) // FUNCTION: LEGO1 0x10076670 @@ -98,6 +101,7 @@ MxLong Bike::HandleControl(LegoControlManagerNotificationParam& p_param) MxSoundPresenter* presenter = (MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "BikeHorn_Sound"); presenter->Enable(p_param.m_enabledChild); + Extension::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp b/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp index 03c97f7b..47a7e7f5 100644 --- a/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp +++ b/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp @@ -1,6 +1,7 @@ #include "dunebuggy.h" #include "decomp.h" +#include "extensions/multiplayer.h" #include "isle.h" #include "isle_actions.h" #include "jukebox_actions.h" @@ -21,6 +22,8 @@ #include #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(DuneBuggy, 0x16c) // GLOBAL: LEGO1 0x100f7660 @@ -141,6 +144,7 @@ MxLong DuneBuggy::HandleControl(LegoControlManagerNotificationParam& p_param) MxSoundPresenter* presenter = (MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "DuneCarHorn_Sound"); presenter->Enable(p_param.m_enabledChild); + Extension::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/towtrack.cpp b/LEGO1/lego/legoomni/src/actors/towtrack.cpp index 301c9d44..2d0e2bef 100644 --- a/LEGO1/lego/legoomni/src/actors/towtrack.cpp +++ b/LEGO1/lego/legoomni/src/actors/towtrack.cpp @@ -1,5 +1,6 @@ #include "towtrack.h" +#include "extensions/multiplayer.h" #include "isle.h" #include "isle_actions.h" #include "jukebox_actions.h" @@ -22,6 +23,8 @@ #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(TowTrack, 0x180) DECOMP_SIZE_ASSERT(TowTrackMissionState, 0x28) @@ -502,6 +505,7 @@ MxLong TowTrack::HandleControl(LegoControlManagerNotificationParam& p_param) case IsleScript::c_TowHorn_Ctl: MxSoundPresenter* presenter = (MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "TowHorn_Sound"); presenter->Enable(p_param.m_enabledChild); + Extension::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/extensions/include/extensions/common/animutils.h b/extensions/include/extensions/common/animutils.h index 4256e4e2..c9a779e5 100644 --- a/extensions/include/extensions/common/animutils.h +++ b/extensions/include/extensions/common/animutils.h @@ -97,6 +97,14 @@ void ApplyTree(LegoAnim* p_anim, MxMatrix& p_transform, LegoTime p_time, LegoROI // Each clone gets its own transform, safe for concurrent animation playback. LegoROI* DeepCloneROI(LegoROI* p_source, const char* p_name); +// Compute child-to-parent local offsets for a hierarchical ROI. +// Returns one MxMatrix per compound child: offset = inverse(parent) * child. +std::vector ComputeChildOffsets(LegoROI* p_parent); + +// Apply a new parent transform to a hierarchical ROI, positioning children +// using precomputed local offsets: child_world = parent_world * offset. +void ApplyHierarchyTransform(LegoROI* p_parent, const MxMatrix& p_transform, const std::vector& p_offsets); + // 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); diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index 0d3078f5..62b76bcd 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -42,6 +42,7 @@ class MultiplayerExt { static std::map options; static bool enabled; + static void HandleHornPressed(MxU32 p_controlId); static MxBool IsClonedCharacter(const char* p_name); static void HandleBeforeSaveLoad(); static void HandleSaveLoaded(); @@ -69,6 +70,7 @@ constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; constexpr auto HandleSkyLightControl = &MultiplayerExt::HandleSkyLightControl; constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick; +constexpr auto HandleHornPressed = &MultiplayerExt::HandleHornPressed; constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; @@ -79,6 +81,7 @@ constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullp constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; constexpr decltype(&MultiplayerExt::HandleSkyLightControl) HandleSkyLightControl = nullptr; constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr; +constexpr decltype(&MultiplayerExt::HandleHornPressed) HandleHornPressed = nullptr; constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; diff --git a/extensions/include/extensions/multiplayer/animation/loader.h b/extensions/include/extensions/multiplayer/animation/loader.h index df566aab..4cfff986 100644 --- a/extensions/include/extensions/multiplayer/animation/loader.h +++ b/extensions/include/extensions/multiplayer/animation/loader.h @@ -81,6 +81,10 @@ class Loader { SceneAnimData* EnsureCached(uint32_t p_objectId); void PreloadAsync(uint32_t p_objectId); + // Extract just the first WAV audio track from a composite SI object. + // Used for horn sounds from dashboard composites (which have no animation). + SceneAnimData::AudioTrack* EnsureHornCached(uint32_t p_objectId); + private: class PreloadThread : public MxThread { public: @@ -104,6 +108,7 @@ class Loader { si::Interleaf* m_interleaf; bool m_siReady; std::map m_cache; + std::map m_hornCache; MxCriticalSection m_cacheCS; PreloadThread* m_preloadThread; diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index e8a4abbb..07250360 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -65,6 +65,7 @@ class NetworkManager : public MxCore { void SetWalkAnimation(uint8_t p_walkAnimId); void SetIdleAnimation(uint8_t p_idleAnimId); void SendEmote(uint8_t p_emoteId); + void SendHorn(int8_t p_vehicleType); // Thread-safe request methods for cross-thread callers (e.g. WASM exports // running on the browser main thread). Deferred to the game thread in Tickle(). @@ -128,6 +129,7 @@ class NetworkManager : public MxCore { void HandleState(const PlayerStateMsg& p_msg); void HandleHostAssign(const HostAssignMsg& p_msg); void HandleEmote(const EmoteMsg& p_msg); + void HandleHorn(const HornMsg& p_msg); void HandleCustomize(const CustomizeMsg& p_msg); // Animation coordination handlers @@ -215,6 +217,10 @@ class NetworkManager : public MxCore { void StopAllPlayback(); void UnlockRemotesForAnim(uint16_t p_animIndex); + // Horn sound synchronization + void PreloadHornSounds(); + void CleanupHornSounds(); + // Animation state push bool m_animStateDirty; bool m_animInterestDirty; @@ -233,6 +239,11 @@ class NetworkManager : public MxCore { 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 + + // Horn sound data + static const int HORN_VEHICLE_COUNT = 4; + class LegoCacheSound* m_hornTemplates[HORN_VEHICLE_COUNT]; + std::vector m_activeHorns; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index fafc2212..9902d906 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -1,12 +1,12 @@ #pragma once +#include "extensions/common/constants.h" + #include #include #include #include -#include "extensions/common/constants.h" - namespace Multiplayer { @@ -30,20 +30,21 @@ enum MessageType : uint8_t { MSG_ANIM_UPDATE = 13, MSG_ANIM_START = 14, MSG_ANIM_COMPLETE = 15, + MSG_HORN = 16, MSG_ASSIGN_ID = 0xFF }; -using Extensions::Common::VehicleType; -using Extensions::Common::VEHICLE_NONE; +using Extensions::Common::VEHICLE_AMBULANCE; +using Extensions::Common::VEHICLE_BIKE; +using Extensions::Common::VEHICLE_COUNT; +using Extensions::Common::VEHICLE_DUNEBUGGY; using Extensions::Common::VEHICLE_HELICOPTER; using Extensions::Common::VEHICLE_JETSKI; -using Extensions::Common::VEHICLE_DUNEBUGGY; -using Extensions::Common::VEHICLE_BIKE; -using Extensions::Common::VEHICLE_SKATEBOARD; using Extensions::Common::VEHICLE_MOTOCYCLE; +using Extensions::Common::VEHICLE_NONE; +using Extensions::Common::VEHICLE_SKATEBOARD; using Extensions::Common::VEHICLE_TOWTRACK; -using Extensions::Common::VEHICLE_AMBULANCE; -using Extensions::Common::VEHICLE_COUNT; +using Extensions::Common::VehicleType; // Entity types for world events enum WorldEntityType : uint8_t { @@ -53,13 +54,13 @@ enum WorldEntityType : uint8_t { ENTITY_LIGHT = 3 }; -using Extensions::Common::WorldChangeType; -using Extensions::Common::CHANGE_VARIANT; -using Extensions::Common::CHANGE_SOUND; -using Extensions::Common::CHANGE_MOVE; using Extensions::Common::CHANGE_COLOR; -using Extensions::Common::CHANGE_MOOD; using Extensions::Common::CHANGE_DECREMENT; +using Extensions::Common::CHANGE_MOOD; +using Extensions::Common::CHANGE_MOVE; +using Extensions::Common::CHANGE_SOUND; +using Extensions::Common::CHANGE_VARIANT; +using Extensions::Common::WorldChangeType; // Change types for ENTITY_SKY enum SkyChangeType : uint8_t { @@ -177,9 +178,9 @@ struct AnimSlotAssignment { 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 + 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) }; @@ -196,11 +197,17 @@ struct AnimCompletionParticipant { char displayName[8]; // 7 chars + null }; +// One-shot horn sound trigger, broadcast to all peers +struct HornMsg { + MessageHeader header; + uint8_t vehicleType; // VehicleType enum value +}; + // Host -> All: animation completed successfully (natural completion only, not cancellation) struct AnimCompleteMsg { MessageHeader header; - uint64_t eventId; // Random 64-bit ID unique to this completion event - uint32_t objectId; // SI file object ID (stable, used as frontend key) + uint64_t eventId; // Random 64-bit ID unique to this completion event + uint32_t objectId; // SI file object ID (stable, used as frontend key) uint8_t participantCount; AnimCompletionParticipant participants[8]; }; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 79e48f85..a4d4e247 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -4,6 +4,7 @@ #include "extensions/common/customizestate.h" #include "extensions/multiplayer/animation/catalog.h" #include "extensions/multiplayer/protocol.h" +#include "mxgeometry/mxmatrix.h" #include "mxtypes.h" #include @@ -103,6 +104,8 @@ class RemotePlayer { Extensions::Common::CharacterAnimator m_animator; LegoROI* m_vehicleROI; + bool m_vehicleROICloned; + std::vector m_vehicleChildOffsets; // child-to-parent local offsets for cloned hierarchical ROIs NameBubbleRenderer* m_nameBubble; diff --git a/extensions/src/common/animutils.cpp b/extensions/src/common/animutils.cpp index 35c6c2b9..320ee0d9 100644 --- a/extensions/src/common/animutils.cpp +++ b/extensions/src/common/animutils.cpp @@ -328,6 +328,7 @@ LegoROI* AnimUtils::DeepCloneROI(LegoROI* p_source, const char* p_name) clone->SetName(p_name); clone->SetBoundingSphere(p_source->GetBoundingSphere()); + clone->WrappedSetLocal2WorldWithWorldDataUpdate(p_source->GetLocal2World()); const CompoundObject* children = p_source->GetComp(); if (children && !children->empty()) { @@ -346,6 +347,62 @@ LegoROI* AnimUtils::DeepCloneROI(LegoROI* p_source, const char* p_name) return clone; } +// Inverse of an orthonormal affine matrix (rotation + translation). +// R^-1 = R^T, t^-1 = -R^T * t. +static void InvertOrthonormal(MxMatrix& p_out, const MxMatrix& p_in) +{ + p_out.SetIdentity(); + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) { + p_out[r][c] = p_in[c][r]; + } + } + for (int c = 0; c < 3; c++) { + p_out[3][c] = -(p_in[3][0] * p_out[0][c] + p_in[3][1] * p_out[1][c] + p_in[3][2] * p_out[2][c]); + } +} + +std::vector AnimUtils::ComputeChildOffsets(LegoROI* p_parent) +{ + std::vector offsets; + const CompoundObject* children = p_parent->GetComp(); + if (!children) { + return offsets; + } + + MxMatrix parentInv; + InvertOrthonormal(parentInv, p_parent->GetLocal2World()); + + for (auto it = children->begin(); it != children->end(); it++) { + MxMatrix offset; + offset.Product(parentInv, ((LegoROI*) *it)->GetLocal2World()); + offsets.push_back(offset); + } + + return offsets; +} + +void AnimUtils::ApplyHierarchyTransform( + LegoROI* p_parent, + const MxMatrix& p_transform, + const std::vector& p_offsets +) +{ + p_parent->WrappedSetLocal2WorldWithWorldDataUpdate(p_transform); + + const CompoundObject* children = p_parent->GetComp(); + if (!children) { + return; + } + + size_t i = 0; + for (auto it = children->begin(); it != children->end() && i < p_offsets.size(); it++, i++) { + MxMatrix childWorld; + childWorld.Product(p_transform, p_offsets[i]); + ((LegoROI*) *it)->WrappedSetLocal2WorldWithWorldDataUpdate(childWorld); + } +} + std::string AnimUtils::TrimLODSuffix(const std::string& p_name) { std::string result(p_name); diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index c70f5eb8..f4868018 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -265,6 +265,33 @@ MxBool MultiplayerExt::CheckRejected() return FALSE; } +void MultiplayerExt::HandleHornPressed(MxU32 p_controlId) +{ + if (!s_networkManager) { + return; + } + + int8_t vehicleType; + switch (p_controlId) { + case IsleScript::c_BikeHorn_Ctl: + vehicleType = Multiplayer::VEHICLE_BIKE; + break; + case IsleScript::c_AmbulanceHorn_Ctl: + vehicleType = Multiplayer::VEHICLE_AMBULANCE; + break; + case IsleScript::c_TowHorn_Ctl: + vehicleType = Multiplayer::VEHICLE_TOWTRACK; + break; + case IsleScript::c_DuneCarHorn_Ctl: + vehicleType = Multiplayer::VEHICLE_DUNEBUGGY; + break; + default: + return; + } + + s_networkManager->SendHorn(vehicleType); +} + Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager() { return s_networkManager; diff --git a/extensions/src/multiplayer/animation/loader.cpp b/extensions/src/multiplayer/animation/loader.cpp index 55ed4400..30796d96 100644 --- a/extensions/src/multiplayer/animation/loader.cpp +++ b/extensions/src/multiplayer/animation/loader.cpp @@ -104,6 +104,9 @@ Loader::Loader() Loader::~Loader() { CleanupPreloadThread(); + for (auto& [id, track] : m_hornCache) { + delete[] track.pcmData; + } delete m_interleaf; delete m_siFile; } @@ -440,3 +443,46 @@ MxResult Loader::PreloadThread::Run() return MxThread::Run(); } + +SceneAnimData::AudioTrack* Loader::EnsureHornCached(uint32_t p_objectId) +{ + { + AUTOLOCK(m_cacheCS); + auto it = m_hornCache.find(p_objectId); + if (it != m_hornCache.end()) { + return &it->second; + } + } + + if (!OpenSI()) { + return nullptr; + } + + if (!ReadObject(p_objectId)) { + return nullptr; + } + + si::Object* composite = static_cast(m_interleaf->GetChildAt(p_objectId)); + + // Find the first WAV child in the composite (the horn sound) + for (size_t i = 0; i < composite->GetChildCount(); i++) { + si::Object* child = static_cast(composite->GetChildAt(i)); + + if (child->filetype() == si::MxOb::WAV) { + SceneAnimData data; + if (ParseSoundChild(child, data)) { + // Take ownership of the PCM buffer before data's destructor frees it. + // AudioTrack has a raw pointer, so std::move alone doesn't transfer ownership. + SceneAnimData::AudioTrack track = data.audioTracks[0]; + data.audioTracks[0].pcmData = nullptr; + + AUTOLOCK(m_cacheCS); + auto result = m_hornCache.emplace(p_objectId, track); + return &result.first->second; + } + break; + } + } + + return nullptr; +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 79118a8e..ce96025f 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -1,5 +1,6 @@ #include "extensions/multiplayer/networkmanager.h" +#include "actions/isle_actions.h" #include "extensions/common/arearestriction.h" #include "extensions/common/charactercustomizer.h" #include "extensions/common/charactertables.h" @@ -8,6 +9,7 @@ #include "extensions/thirdpersoncamera/controller.h" #include "legoactor.h" #include "legoanimationmanager.h" +#include "legocachsound.h" #include "legocharactermanager.h" #include "legoextraactor.h" #include "legogamestate.h" @@ -69,7 +71,7 @@ NetworkManager::NetworkManager() m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), m_showNameBubbles(true), m_lastCameraEnabled(false), m_lastVehicleState(0), 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) + m_reconnectAttempt(0), m_reconnectDelay(0), m_nextReconnectTime(0), m_hornTemplates{}, m_activeHorns() { } @@ -263,6 +265,8 @@ void NetworkManager::Shutdown() m_worldSync.SetTransport(nullptr); } + CleanupHornSounds(); + delete m_localNameBubble; m_localNameBubble = nullptr; @@ -379,6 +383,7 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) } m_locationProximity.Reset(); + PreloadHornSounds(); } } @@ -393,6 +398,8 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) m_wasInRestrictedArea = false; m_worldSync.SetInIsleWorld(false); + CleanupHornSounds(); + // Stop animation before ROIs are destroyed (calls ResetAnimationState) StopAnimation(); m_animStateDirty = false; // override: we push explicit empty JSON below @@ -830,6 +837,13 @@ void NetworkManager::ProcessIncomingPackets() } break; } + case MSG_HORN: { + HornMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_HORN) { + HandleHorn(msg); + } + break; + } case MSG_CUSTOMIZE: { CustomizeMsg msg; if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_CUSTOMIZE) { @@ -1080,6 +1094,105 @@ void NetworkManager::HandleEmote(const EmoteMsg& p_msg) } } +void NetworkManager::SendHorn(int8_t p_vehicleType) +{ + if (!IsConnected() || !m_inIsleWorld) { + return; + } + + HornMsg msg{}; + msg.header = {MSG_HORN, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST}; + msg.vehicleType = static_cast(p_vehicleType); + SendMessage(msg); +} + +void NetworkManager::HandleHorn(const HornMsg& p_msg) +{ + // Sweep finished horn sounds + for (auto it = m_activeHorns.begin(); it != m_activeHorns.end();) { + if (!ma_sound_is_playing((*it)->m_cacheSound)) { + (*it)->Stop(); + delete *it; + it = m_activeHorns.erase(it); + } + else { + ++it; + } + } + + uint32_t peerId = p_msg.header.peerId; + auto it = m_remotePlayers.find(peerId); + if (it == m_remotePlayers.end()) { + return; + } + + // Map vehicle type to horn template index + static const int8_t hornVehicles[] = {VEHICLE_BIKE, VEHICLE_AMBULANCE, VEHICLE_TOWTRACK, VEHICLE_DUNEBUGGY}; + int templateIdx = -1; + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + if (hornVehicles[i] == static_cast(p_msg.vehicleType)) { + templateIdx = i; + break; + } + } + + if (templateIdx < 0 || !m_hornTemplates[templateIdx]) { + return; + } + + LegoCacheSound* horn = m_hornTemplates[templateIdx]->Clone(); + if (horn) { + ma_sound_set_doppler_factor(horn->m_cacheSound, 0); + horn->Play(it->second->GetUniqueName(), FALSE); + m_activeHorns.push_back(horn); + } +} + +// Dashboard composite IDs that contain horn WAV children +static const uint32_t g_hornDashboardIds[4] = { + IsleScript::c_BikeDashboard, + IsleScript::c_AmbulanceDashboard, + IsleScript::c_TowTrackDashboard, + IsleScript::c_DuneCarDashboard, +}; + +void NetworkManager::PreloadHornSounds() +{ + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + m_hornTemplates[i] = nullptr; + + Animation::SceneAnimData::AudioTrack* track = m_animLoader.EnsureHornCached(g_hornDashboardIds[i]); + if (!track) { + continue; + } + + LegoCacheSound* sound = new LegoCacheSound(); + MxString mediaSrcPath(track->mediaSrcPath.c_str()); + MxWavePresenter::WaveFormat format = track->format; + if (sound->Create(format, mediaSrcPath, track->volume, track->pcmData, track->pcmDataSize) == SUCCESS) { + ma_sound_set_doppler_factor(sound->m_cacheSound, 0); + m_hornTemplates[i] = sound; + } + else { + delete sound; + } + } +} + +void NetworkManager::CleanupHornSounds() +{ + for (auto* horn : m_activeHorns) { + horn->Stop(); + delete horn; + } + m_activeHorns.clear(); + + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + delete m_hornTemplates[i]; + m_hornTemplates[i] = nullptr; + } +} + void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) { auto it = m_remotePlayers.find(p_peerId); @@ -2185,7 +2298,8 @@ void NetworkManager::PushAnimationState() if (player->IsAtLocation(loc)) { int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); locationCharIndices.push_back(charIdx); - locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI())); + locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI()) + ); } } diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index b8d2fb23..63fd99c0 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -1,6 +1,7 @@ #include "extensions/multiplayer/remoteplayer.h" #include "3dmanager/lego3dmanager.h" +#include "extensions/common/animutils.h" #include "extensions/common/arearestriction.h" #include "extensions/common/charactercloner.h" #include "extensions/common/charactercustomizer.h" @@ -32,7 +33,7 @@ RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displ 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_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}), - m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true), + m_vehicleROI(nullptr), m_vehicleROICloned(false), m_nameBubble(nullptr), m_allowRemoteCustomize(true), m_lockedForAnimIndex(Animation::ANIM_INDEX_NONE) { m_displayName[0] = '\0'; @@ -307,7 +308,12 @@ void RemotePlayer::UpdateTransform(float p_deltaTime) if (m_vehicleROI && m_animator.GetCurrentVehicleType() != VEHICLE_NONE && IsLargeVehicle(m_animator.GetCurrentVehicleType())) { - m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat); + if (m_vehicleROICloned && !m_vehicleChildOffsets.empty()) { + Common::AnimUtils::ApplyHierarchyTransform(m_vehicleROI, mat, m_vehicleChildOffsets); + } + else { + m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat); + } VideoManager()->Get3DManager()->Moved(*m_vehicleROI); } } @@ -338,10 +344,30 @@ void RemotePlayer::EnterVehicle(int8_t p_vehicleType) SDL_snprintf(vehicleName, sizeof(vehicleName), "%s_mp_%u", g_vehicleROINames[p_vehicleType], m_peerId); m_vehicleROI = CharacterManager()->CreateAutoROI(vehicleName, g_vehicleROINames[p_vehicleType], FALSE); + + if (!m_vehicleROI) { + // Fallback for hierarchical models whose root has 0 LODs + // and cannot be created via CreateAutoROI. Deep-clone the world's existing ROI. + LegoROI* source = FindROI(g_vehicleROINames[p_vehicleType]); + if (source) { + m_vehicleROI = Common::AnimUtils::DeepCloneROI(source, vehicleName); + if (m_vehicleROI) { + VideoManager()->Get3DManager()->Add(*m_vehicleROI); + m_vehicleROICloned = true; + } + } + } + if (m_vehicleROI) { m_roi->SetVisibility(FALSE); MxMatrix mat(m_roi->GetLocal2World()); - m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat); + if (m_vehicleROICloned) { + m_vehicleChildOffsets = Common::AnimUtils::ComputeChildOffsets(m_vehicleROI); + Common::AnimUtils::ApplyHierarchyTransform(m_vehicleROI, mat, m_vehicleChildOffsets); + } + else { + m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat); + } m_vehicleROI->SetVisibility(m_visible ? TRUE : FALSE); } } @@ -358,8 +384,15 @@ void RemotePlayer::ExitVehicle() if (m_vehicleROI) { VideoManager()->Get3DManager()->Remove(*m_vehicleROI); - CharacterManager()->ReleaseAutoROI(m_vehicleROI); + if (m_vehicleROICloned) { + delete m_vehicleROI; + } + else { + CharacterManager()->ReleaseAutoROI(m_vehicleROI); + } m_vehicleROI = nullptr; + m_vehicleROICloned = false; + m_vehicleChildOffsets.clear(); } m_animator.ClearRideAnimation();