diff --git a/CMakeLists.txt b/CMakeLists.txt index 272513bf..88ab179b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -534,6 +534,8 @@ if (ISLE_EXTENSIONS) extensions/src/multiplayer.cpp extensions/src/multiplayer/animutils.cpp extensions/src/multiplayer/charactercloner.cpp + extensions/src/multiplayer/charactercustomizer.cpp + extensions/src/multiplayer/customizestate.cpp extensions/src/multiplayer/namebubblerenderer.cpp extensions/src/multiplayer/networkmanager.cpp extensions/src/multiplayer/protocol.cpp diff --git a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp index 180b8eec..93effa24 100644 --- a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp +++ b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp @@ -1,6 +1,7 @@ #include "legocharactermanager.h" #include "3dmanager/lego3dmanager.h" +#include "extensions/multiplayer.h" #include "legoactors.h" #include "legoanimactor.h" #include "legobuildingmanager.h" @@ -22,6 +23,8 @@ #include #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(LegoCharacter, 0x08) DECOMP_SIZE_ASSERT(LegoCharacterManager, 0x08) DECOMP_SIZE_ASSERT(CustomizeAnimFileVariable, 0x24) @@ -279,7 +282,8 @@ LegoROI* LegoCharacterManager::GetActorROI(const char* p_name, MxBool p_createEn } if (character != NULL) { - if (p_createEntity && character->m_roi->GetEntity() == NULL) { + if (p_createEntity && character->m_roi->GetEntity() == NULL && + !Extension::Call(IsClonedCharacter, p_name).value_or(FALSE)) { LegoExtraActor* actor = new LegoExtraActor(); actor->SetROI(character->m_roi, FALSE, FALSE); diff --git a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp index aa631027..39fd543e 100644 --- a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp +++ b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp @@ -1,5 +1,6 @@ #include "legoinputmanager.h" +#include "extensions/multiplayer.h" #include "legocameracontroller.h" #include "legocontrolmanager.h" #include "legomain.h" @@ -14,6 +15,8 @@ #include +using namespace Extensions; + DECOMP_SIZE_ASSERT(LegoInputManager, 0x338) DECOMP_SIZE_ASSERT(LegoNotifyList, 0x18) DECOMP_SIZE_ASSERT(LegoNotifyListCursor, 0x10) @@ -393,6 +396,9 @@ MxBool LegoInputManager::ProcessOneEvent(LegoEventNotificationParam& p_param) if (entity && entity->Notify(p_param) != 0) { return TRUE; } + if (Extension::Call(HandleROIClick, roi, p_param).value_or(FALSE)) { + return TRUE; + } } } diff --git a/LEGO1/lego/legoomni/src/main/legomain.cpp b/LEGO1/lego/legoomni/src/main/legomain.cpp index 0f455d7a..ad5eca5f 100644 --- a/LEGO1/lego/legoomni/src/main/legomain.cpp +++ b/LEGO1/lego/legoomni/src/main/legomain.cpp @@ -1,6 +1,7 @@ #include "legomain.h" #include "3dmanager/lego3dmanager.h" +#include "extensions/multiplayer.h" #include "extensions/siloader.h" #include "islepathactor.h" #include "legoanimationmanager.h" @@ -355,6 +356,7 @@ MxResult LegoOmni::Create(MxOmniCreateParam& p_param) m_gameState->SetCurrentAct(LegoGameState::e_act1); #endif + Extension::Call(HandleCreate); result = SUCCESS; } else { diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index 9408d1f5..214809f2 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -8,7 +8,9 @@ class IslePathActor; class LegoEntity; +class LegoEventNotificationParam; class LegoPathActor; +class LegoROI; class LegoWorld; namespace Multiplayer @@ -24,12 +26,16 @@ namespace Extensions class MultiplayerExt { public: static void Initialize(); + static void HandleCreate(); static MxBool HandleWorldEnable(LegoWorld* p_world, MxBool p_enable); // Intercepts click notifications on plants/buildings for multiplayer routing. // Returns TRUE if the click should be suppressed locally (non-host). static MxBool HandleEntityNotify(LegoEntity* p_entity); + // Handles clicks on entity-less ROIs (remote players, display actor overrides). + static MxBool HandleROIClick(LegoROI* p_rootROI, LegoEventNotificationParam& p_param); + static std::map options; static bool enabled; @@ -41,6 +47,9 @@ class MultiplayerExt { static void HandleCamAnimEnd(LegoPathActor* p_actor); static MxBool ShouldInvertMovement(LegoPathActor* p_actor); + // Returns TRUE if the name belongs to a multiplayer clone (entity-less ROI). + static MxBool IsClonedCharacter(const char* p_name); + // Returns true if the multiplayer connection was rejected (e.g. room full). static MxBool CheckRejected(); @@ -56,20 +65,26 @@ class MultiplayerExt { #ifdef EXTENSIONS LEGO1_EXPORT bool IsMultiplayerRejected(); +constexpr auto HandleCreate = &MultiplayerExt::HandleCreate; constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; +constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick; constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter; constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit; constexpr auto HandleCamAnimEnd = &MultiplayerExt::HandleCamAnimEnd; constexpr auto ShouldInvertMovement = &MultiplayerExt::ShouldInvertMovement; +constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; #else +constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; +constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr; constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr; constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr; constexpr decltype(&MultiplayerExt::HandleCamAnimEnd) HandleCamAnimEnd = nullptr; constexpr decltype(&MultiplayerExt::ShouldInvertMovement) ShouldInvertMovement = nullptr; +constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; #endif diff --git a/extensions/include/extensions/multiplayer/charactercustomizer.h b/extensions/include/extensions/multiplayer/charactercustomizer.h new file mode 100644 index 00000000..ca5e7d57 --- /dev/null +++ b/extensions/include/extensions/multiplayer/charactercustomizer.h @@ -0,0 +1,40 @@ +#pragma once + +#include "mxtypes.h" + +#include + +class LegoROI; + +namespace Multiplayer +{ + +struct CustomizeState; + +class CharacterCustomizer { +public: + static uint8_t ResolveActorInfoIndex(uint8_t p_displayActorIndex, uint8_t p_actorId); + + static bool SwitchColor(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, + CustomizeState& p_state, int p_partIndex); + static bool SwitchVariant(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, + CustomizeState& p_state); + static bool SwitchSound(CustomizeState& p_state); + static bool SwitchMove(CustomizeState& p_state); + static bool SwitchMood(CustomizeState& p_state); + static void ApplyFullState(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, + const CustomizeState& p_state); + static void ApplyChange(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, + CustomizeState& p_state, uint8_t p_changeType, uint8_t p_partIndex); + static int MapClickedPartIndex(const char* p_partName); + static void PlayClickSound(LegoROI* p_roi, const CustomizeState& p_state, bool p_basedOnMood); + static MxU32 PlayClickAnimation(LegoROI* p_roi, const CustomizeState& p_state); + static void StopClickAnimation(MxU32 p_objectId); + +private: + static LegoROI* FindChildROI(LegoROI* p_rootROI, const char* p_name); + static void ApplyHatVariant(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, + const CustomizeState& p_state); +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/customizestate.h b/extensions/include/extensions/multiplayer/customizestate.h new file mode 100644 index 00000000..0cc8c1e0 --- /dev/null +++ b/extensions/include/extensions/multiplayer/customizestate.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace Multiplayer +{ + +struct CustomizeState { + uint8_t colorIndices[10] = {}; // m_nameIndex per body part (matching LegoActorInfo::Part::m_nameIndex) + uint8_t hatVariantIndex = 0; // m_partNameIndex for infohat part + uint8_t sound = 0; // 0 to 8 + uint8_t move = 0; // 0 to 3 + uint8_t mood = 0; // 0 to 3 + + void InitFromActorInfo(uint8_t p_actorInfoIndex); + void Pack(uint8_t p_out[5]) const; + void Unpack(const uint8_t p_in[5]); + bool operator==(const CustomizeState& p_other) const; + bool operator!=(const CustomizeState& p_other) const { return !(*this == p_other); } +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 3171690b..08855432 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -37,6 +37,7 @@ class NetworkManager : public MxCore { } void Initialize(NetworkTransport* p_transport, PlatformCallbacks* p_callbacks); + void HandleCreate(); void Shutdown(); void Connect(const char* p_roomId); @@ -44,21 +45,26 @@ class NetworkManager : public MxCore { bool IsConnected() const; bool WasRejected() const; - void SetWalkAnimation(uint8_t p_index); - void SetIdleAnimation(uint8_t p_index); + void SetWalkAnimation(uint8_t p_walkAnimId); + void SetIdleAnimation(uint8_t p_idleAnimId); void SendEmote(uint8_t p_emoteId); - void SetDisplayActorIndex(uint8_t p_index); + void SetDisplayActorIndex(uint8_t p_displayActorIndex); // 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(). void RequestToggleThirdPerson() { m_pendingToggleThirdPerson.store(true, std::memory_order_relaxed); } - void RequestSetWalkAnimation(uint8_t p_index) { m_pendingWalkAnim.store(p_index, std::memory_order_relaxed); } - void RequestSetIdleAnimation(uint8_t p_index) { m_pendingIdleAnim.store(p_index, std::memory_order_relaxed); } + void RequestSetWalkAnimation(uint8_t p_walkAnimId) { m_pendingWalkAnim.store(p_walkAnimId, std::memory_order_relaxed); } + void RequestSetIdleAnimation(uint8_t p_idleAnimId) { m_pendingIdleAnim.store(p_idleAnimId, std::memory_order_relaxed); } void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); } void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); } + void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.store(true, std::memory_order_relaxed); } bool GetShowNameBubbles() const { return m_showNameBubbles; } + RemotePlayer* FindPlayerByROI(LegoROI* roi) const; + bool IsClonedCharacter(const char* p_name) const; + void SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex); + void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -69,6 +75,7 @@ class NetworkManager : public MxCore { MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType); bool IsHost() const { return m_localPeerId != 0 && m_localPeerId == m_hostPeerId; } + uint32_t GetLocalPeerId() const { return m_localPeerId; } private: void BroadcastLocalState(); @@ -82,6 +89,7 @@ class NetworkManager : public MxCore { void HandleState(const PlayerStateMsg& p_msg); void HandleHostAssign(const HostAssignMsg& p_msg); void HandleEmote(const EmoteMsg& p_msg); + void HandleCustomize(const CustomizeMsg& p_msg); void ProcessPendingRequests(); void RemoveRemotePlayer(uint32_t p_peerId); @@ -98,6 +106,7 @@ class NetworkManager : public MxCore { WorldStateSync m_worldSync; ThirdPersonCamera m_thirdPersonCamera; std::map> m_remotePlayers; + std::map m_roiToPlayer; uint32_t m_localPeerId; uint32_t m_hostPeerId; @@ -107,6 +116,7 @@ class NetworkManager : public MxCore { uint8_t m_localWalkAnimId; uint8_t m_localIdleAnimId; uint8_t m_localDisplayActorIndex; + bool m_localAllowRemoteCustomize; bool m_inIsleWorld; bool m_registered; @@ -115,6 +125,7 @@ class NetworkManager : public MxCore { std::atomic m_pendingWalkAnim; std::atomic m_pendingIdleAnim; std::atomic m_pendingEmote; + std::atomic m_pendingToggleAllowCustomize; bool m_showNameBubbles; diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index eccb4204..f8374af5 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -20,6 +20,7 @@ enum MessageType : uint8_t { MSG_WORLD_EVENT = 7, MSG_WORLD_EVENT_REQUEST = 8, MSG_EMOTE = 9, + MSG_CUSTOMIZE = 10, MSG_ASSIGN_ID = 0xFF }; @@ -83,6 +84,8 @@ struct PlayerStateMsg { uint8_t idleAnimId; // Index into idle animation table (0 = default) char name[8]; // Player display name (7 chars + null terminator) uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) + uint8_t customizeData[5]; // Packed CustomizeState + uint8_t customizeFlags; // Bit 0 = allowRemoteCustomize }; // Server -> all: announces which peer is the host @@ -129,6 +132,14 @@ struct EmoteMsg { uint8_t emoteId; // Index into emote table }; +// Immediate customization change, broadcast to all peers +struct CustomizeMsg { + MessageHeader header; + uint32_t targetPeerId; // Who is being customized + uint8_t changeType; // WorldChangeType (VARIANT/SOUND/MOVE/COLOR/MOOD) + uint8_t partIndex; // Body part for color changes (0-9), 0xFF otherwise +}; + #pragma pack(pop) // Animation and vehicle tables (defined in protocol.cpp) diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 8ed7e2f4..53ccfa7f 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -1,6 +1,7 @@ #pragma once #include "extensions/multiplayer/animutils.h" +#include "extensions/multiplayer/customizestate.h" #include "extensions/multiplayer/protocol.h" #include "mxtypes.h" @@ -30,9 +31,11 @@ class RemotePlayer { void ReAddToScene(); uint32_t GetPeerId() const { return m_peerId; } + const char* GetUniqueName() const { return m_uniqueName; } uint8_t GetActorId() const { return m_actorId; } uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; } void SetActorId(uint8_t p_actorId) { m_actorId = p_actorId; } + LegoROI* GetROI() const { return m_roi; } bool IsSpawned() const { return m_spawned; } bool IsVisible() const { return m_visible; } int8_t GetWorldId() const { return m_targetWorldId; } @@ -43,6 +46,13 @@ class RemotePlayer { void CreateNameBubble(); void DestroyNameBubble(); + const CustomizeState& GetCustomizeState() const { return m_customizeState; } + bool GetAllowRemoteCustomize() const { return m_allowRemoteCustomize; } + void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_clickAnimObjectId = p_clickAnimObjectId; } + void StopClickAnimation(); + bool IsInVehicle() const { return m_currentVehicleType != VEHICLE_NONE; } + bool IsMoving() const { return m_currentVehicleType != VEHICLE_NONE || m_targetSpeed > 0.01f; } + private: using AnimCache = AnimUtils::AnimCache; @@ -93,6 +103,9 @@ class RemotePlayer { float m_emoteDuration; bool m_emoteActive; + // Click animation tracking (0 = none) + MxU32 m_clickAnimObjectId; + // ROI map cache: animation name -> cached ROI map (invalidated on world change) std::map m_animCacheMap; @@ -106,6 +119,9 @@ class RemotePlayer { int8_t m_currentVehicleType; NameBubbleRenderer* m_nameBubble; + + CustomizeState m_customizeState; + bool m_allowRemoteCustomize; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h index 07156f3b..e68a2359 100644 --- a/extensions/include/extensions/multiplayer/thirdpersoncamera.h +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -1,6 +1,7 @@ #pragma once #include "extensions/multiplayer/animutils.h" +#include "extensions/multiplayer/customizestate.h" #include "extensions/multiplayer/protocol.h" #include "mxgeometry/mxmatrix.h" #include "mxtypes.h" @@ -36,10 +37,18 @@ class ThirdPersonCamera { void Tick(float p_deltaTime); // Animation selection (forwarded from NetworkManager) - void SetWalkAnimId(uint8_t p_id); - void SetIdleAnimId(uint8_t p_id); + void SetWalkAnimId(uint8_t p_walkAnimId); + void SetIdleAnimId(uint8_t p_idleAnimId); void TriggerEmote(uint8_t p_emoteId); - void SetDisplayActorIndex(uint8_t p_index); + void SetDisplayActorIndex(uint8_t p_displayActorIndex); + uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; } + LegoROI* GetDisplayROI() const { return m_displayROI; } + CustomizeState& GetCustomizeState() { return m_customizeState; } + + void ApplyCustomizeChange(uint8_t p_changeType, uint8_t p_partIndex); + void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_clickAnimObjectId = p_clickAnimObjectId; } + void StopClickAnimation(); + bool IsInVehicle() const { return m_currentVehicleType != VEHICLE_NONE; } void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -69,6 +78,7 @@ class ThirdPersonCamera { uint8_t m_displayActorIndex; LegoROI* m_displayROI; // Owned clone; nullptr = use native ROI char m_displayUniqueName[32]; + CustomizeState m_customizeState; // Walk/idle state (same pattern as RemotePlayer) uint8_t m_walkAnimId; @@ -87,6 +97,9 @@ class ThirdPersonCamera { bool m_emoteActive; MxMatrix m_emoteParentTransform; + // Click animation tracking (0 = none) + MxU32 m_clickAnimObjectId; + // Vehicle ride state int8_t m_currentVehicleType; LegoAnim* m_rideAnim; diff --git a/extensions/include/extensions/multiplayer/worldstatesync.h b/extensions/include/extensions/multiplayer/worldstatesync.h index f0245f00..b4eab8db 100644 --- a/extensions/include/extensions/multiplayer/worldstatesync.h +++ b/extensions/include/extensions/multiplayer/worldstatesync.h @@ -17,9 +17,9 @@ class WorldStateSync { WorldStateSync(); void SetTransport(NetworkTransport* p_transport) { m_transport = p_transport; } - void SetLocalPeerId(uint32_t p_peerId) { m_localPeerId = p_peerId; } + void SetLocalPeerId(uint32_t p_localPeerId) { m_localPeerId = p_localPeerId; } void SetHost(bool p_isHost) { m_isHost = p_isHost; } - void SetInIsleWorld(bool p_inIsle) { m_inIsleWorld = p_inIsle; } + void SetInIsleWorld(bool p_inIsleWorld) { m_inIsleWorld = p_inIsleWorld; } // Called when the host peer changes. Requests a snapshot if we're not host. void OnHostChanged(); diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index 01bda091..a756fb58 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -1,6 +1,6 @@ #include "extensions/multiplayer.h" -#include "extensions/extensions.h" +#include "extensions/multiplayer/charactercustomizer.h" #include "extensions/multiplayer/networkmanager.h" #include "extensions/multiplayer/networktransport.h" #include "extensions/multiplayer/protocol.h" @@ -8,9 +8,11 @@ #include "legoactor.h" #include "legoactors.h" #include "legoentity.h" +#include "legoeventnotificationparam.h" #include "legogamestate.h" #include "legopathactor.h" #include "misc.h" +#include "roi/legoroi.h" #include @@ -70,6 +72,13 @@ void MultiplayerExt::Initialize() #endif } +void MultiplayerExt::HandleCreate() +{ + if (s_networkManager) { + s_networkManager->HandleCreate(); + } +} + MxBool MultiplayerExt::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable) { if (!s_networkManager) { @@ -86,6 +95,79 @@ MxBool MultiplayerExt::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable) return TRUE; } +MxBool MultiplayerExt::HandleROIClick(LegoROI* p_rootROI, LegoEventNotificationParam& p_param) +{ + if (!s_networkManager) { + return FALSE; + } + + Multiplayer::NetworkManager* mgr = s_networkManager; + + // Check if it's a remote player + Multiplayer::RemotePlayer* remote = mgr->FindPlayerByROI(p_rootROI); + + // Check if it's our own 3rd-person display actor override + bool isSelf = (mgr->GetThirdPersonCamera().GetDisplayROI() != nullptr && + mgr->GetThirdPersonCamera().GetDisplayROI() == p_rootROI); + + if (!remote && !isSelf) { + return FALSE; + } + + // Remote player permission check + if (remote && !remote->GetAllowRemoteCustomize()) { + return TRUE; // Consume click, no effect + } + + // Determine change type from clicker's actor ID + uint8_t changeType; + int partIndex = -1; + switch (GameState()->GetActorId()) { + case LegoActor::c_pepper: + if (GameState()->GetCurrentAct() == LegoGameState::e_act2 || + GameState()->GetCurrentAct() == LegoGameState::e_act3) { + return TRUE; + } + changeType = Multiplayer::CHANGE_VARIANT; + break; + case LegoActor::c_mama: + changeType = Multiplayer::CHANGE_SOUND; + break; + case LegoActor::c_papa: + changeType = Multiplayer::CHANGE_MOVE; + break; + case LegoActor::c_nick: + changeType = Multiplayer::CHANGE_COLOR; + if (p_param.GetROI()) { + partIndex = Multiplayer::CharacterCustomizer::MapClickedPartIndex(p_param.GetROI()->GetName()); + } + if (partIndex < 0) { + return TRUE; + } + break; + case LegoActor::c_laura: + changeType = Multiplayer::CHANGE_MOOD; + break; + case LegoActor::c_brickster: + return TRUE; + default: + return FALSE; + } + + // Send a customize request to the server. The server echoes it back to all peers + // (including the sender). HandleCustomize then applies the change and plays effects. + // For remote targets this avoids flip-flop from stale state messages; for self targets + // it keeps the code path uniform. + uint32_t targetPeerId = remote ? remote->GetPeerId() : mgr->GetLocalPeerId(); + mgr->SendCustomize( + targetPeerId, + changeType, + static_cast(partIndex >= 0 ? partIndex : 0xFF) + ); + + return TRUE; +} + MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity) { if (!s_networkManager) { @@ -161,6 +243,15 @@ MxBool MultiplayerExt::ShouldInvertMovement(LegoPathActor* p_actor) return FALSE; } +MxBool MultiplayerExt::IsClonedCharacter(const char* p_name) +{ + if (!s_networkManager) { + return FALSE; + } + + return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE; +} + MxBool MultiplayerExt::CheckRejected() { if (s_networkManager && s_networkManager->WasRejected()) { diff --git a/extensions/src/multiplayer/charactercustomizer.cpp b/extensions/src/multiplayer/charactercustomizer.cpp new file mode 100644 index 00000000..94b3e435 --- /dev/null +++ b/extensions/src/multiplayer/charactercustomizer.cpp @@ -0,0 +1,347 @@ +#include "extensions/multiplayer/charactercustomizer.h" + +#include "extensions/multiplayer/charactercloner.h" +#include "extensions/multiplayer/customizestate.h" +#include "extensions/multiplayer/protocol.h" + +#include "3dmanager/lego3dmanager.h" +#include "3dmanager/lego3dview.h" +#include "legoactors.h" +#include "legocharactermanager.h" +#include "legovideomanager.h" +#include "misc.h" +#include "mxatom.h" +#include "mxdsaction.h" +#include "mxmisc.h" +#include "roi/legolod.h" +#include "roi/legoroi.h" +#include "viewmanager/viewlodlist.h" +#include "viewmanager/viewmanager.h" + +#include +#include + +using namespace Multiplayer; + +static const MxU32 g_characterSoundIdOffset = 50; +static const MxU32 g_characterSoundIdMoodOffset = 66; +static const MxU32 g_characterAnimationId = 10; +static const MxU32 g_maxSound = 9; +static const MxU32 g_maxMove = 4; + +static uint32_t s_variantCounter = 10000; + +// MARK: Private helpers + +LegoROI* CharacterCustomizer::FindChildROI(LegoROI* p_rootROI, const char* p_name) +{ + const CompoundObject* comp = p_rootROI->GetComp(); + + for (CompoundObject::const_iterator it = comp->begin(); it != comp->end(); it++) { + LegoROI* roi = (LegoROI*) *it; + + if (!SDL_strcasecmp(p_name, roi->GetName())) { + return roi; + } + } + + return NULL; +} + +// MARK: Public API + +uint8_t CharacterCustomizer::ResolveActorInfoIndex(uint8_t p_displayActorIndex, uint8_t p_actorId) +{ + if (IsValidDisplayActorIndex(p_displayActorIndex)) { + return p_displayActorIndex; + } + + if (p_actorId >= 1 && p_actorId <= 5) { + return p_actorId - 1; + } + + return 0; +} + +bool CharacterCustomizer::SwitchColor( + LegoROI* p_rootROI, + uint8_t p_actorInfoIndex, + CustomizeState& p_state, + int p_partIndex +) +{ + if (p_partIndex < 0 || p_partIndex >= 10) { + return false; + } + + // Remap derived parts to independent parts + if (p_partIndex == c_clawlftPart) { + p_partIndex = c_armlftPart; + } + else if (p_partIndex == c_clawrtPart) { + p_partIndex = c_armrtPart; + } + else if (p_partIndex == c_headPart) { + p_partIndex = c_infohatPart; + } + else if (p_partIndex == c_bodyPart) { + p_partIndex = c_infogronPart; + } + + if (!(g_actorLODs[p_partIndex + 1].m_flags & LegoActorLOD::c_useColor)) { + return false; + } + + if (p_actorInfoIndex >= sizeOfArray(g_actorInfoInit)) { + return false; + } + + const LegoActorInfo::Part& part = g_actorInfoInit[p_actorInfoIndex].m_parts[p_partIndex]; + + p_state.colorIndices[p_partIndex]++; + if (part.m_nameIndices[p_state.colorIndices[p_partIndex]] == 0xff) { + p_state.colorIndices[p_partIndex] = 0; + } + + if (!p_rootROI) { + return true; + } + + LegoROI* targetROI = FindChildROI(p_rootROI, g_actorLODs[p_partIndex + 1].m_name); + if (!targetROI) { + return false; + } + + LegoFloat red, green, blue, alpha; + LegoROI::GetRGBAColor(part.m_names[part.m_nameIndices[p_state.colorIndices[p_partIndex]]], red, green, blue, alpha); + targetROI->SetLodColor(red, green, blue, alpha); + return true; +} + +bool CharacterCustomizer::SwitchVariant(LegoROI* p_rootROI, uint8_t p_actorInfoIndex, CustomizeState& p_state) +{ + if (p_actorInfoIndex >= sizeOfArray(g_actorInfoInit)) { + return false; + } + + const LegoActorInfo::Part& part = g_actorInfoInit[p_actorInfoIndex].m_parts[c_infohatPart]; + + p_state.hatVariantIndex++; + if (part.m_partNameIndices[p_state.hatVariantIndex] == 0xff) { + p_state.hatVariantIndex = 0; + } + + if (!p_rootROI) { + return true; + } + + ApplyHatVariant(p_rootROI, p_actorInfoIndex, p_state); + return true; +} + +bool CharacterCustomizer::SwitchSound(CustomizeState& p_state) +{ + p_state.sound++; + if (p_state.sound >= g_maxSound) { + p_state.sound = 0; + } + return true; +} + +bool CharacterCustomizer::SwitchMove(CustomizeState& p_state) +{ + p_state.move++; + if (p_state.move >= g_maxMove) { + p_state.move = 0; + } + return true; +} + +bool CharacterCustomizer::SwitchMood(CustomizeState& p_state) +{ + p_state.mood++; + if (p_state.mood > 3) { + p_state.mood = 0; + } + return true; +} + +void CharacterCustomizer::ApplyChange( + LegoROI* p_rootROI, + uint8_t p_actorInfoIndex, + CustomizeState& p_state, + uint8_t p_changeType, + uint8_t p_partIndex +) +{ + switch (p_changeType) { + case CHANGE_VARIANT: + SwitchVariant(p_rootROI, p_actorInfoIndex, p_state); + break; + case CHANGE_SOUND: + SwitchSound(p_state); + break; + case CHANGE_MOVE: + SwitchMove(p_state); + break; + case CHANGE_COLOR: + SwitchColor(p_rootROI, p_actorInfoIndex, p_state, p_partIndex); + break; + case CHANGE_MOOD: + SwitchMood(p_state); + break; + } +} + +int CharacterCustomizer::MapClickedPartIndex(const char* p_partName) +{ + for (int i = 0; i < 10; i++) { + if (!SDL_strcasecmp(p_partName, g_actorLODs[i + 1].m_name)) { + return i; + } + } + return -1; +} + +void CharacterCustomizer::ApplyFullState( + LegoROI* p_rootROI, + uint8_t p_actorInfoIndex, + const CustomizeState& p_state +) +{ + if (p_actorInfoIndex >= sizeOfArray(g_actorInfoInit)) { + return; + } + + // Apply colors for the 6 independent colorable parts + static const int colorableParts[] = { + c_infohatPart, c_infogronPart, c_armlftPart, c_armrtPart, c_leglftPart, c_legrtPart + }; + + for (int i = 0; i < (int) sizeOfArray(colorableParts); i++) { + int partIndex = colorableParts[i]; + + if (!(g_actorLODs[partIndex + 1].m_flags & LegoActorLOD::c_useColor)) { + continue; + } + + LegoROI* childROI = FindChildROI(p_rootROI, g_actorLODs[partIndex + 1].m_name); + if (!childROI) { + continue; + } + + const LegoActorInfo::Part& part = g_actorInfoInit[p_actorInfoIndex].m_parts[partIndex]; + + LegoFloat red, green, blue, alpha; + LegoROI::GetRGBAColor( + part.m_names[part.m_nameIndices[p_state.colorIndices[partIndex]]], + red, + green, + blue, + alpha + ); + childROI->SetLodColor(red, green, blue, alpha); + } + + // Apply hat variant if different from default + const LegoActorInfo::Part& hatPart = g_actorInfoInit[p_actorInfoIndex].m_parts[c_infohatPart]; + if (p_state.hatVariantIndex != hatPart.m_partNameIndex) { + ApplyHatVariant(p_rootROI, p_actorInfoIndex, p_state); + } +} + +void CharacterCustomizer::ApplyHatVariant( + LegoROI* p_rootROI, + uint8_t p_actorInfoIndex, + const CustomizeState& p_state +) +{ + if (p_actorInfoIndex >= sizeOfArray(g_actorInfoInit)) { + return; + } + + const LegoActorInfo::Part& part = g_actorInfoInit[p_actorInfoIndex].m_parts[c_infohatPart]; + + MxU8 partNameIndex = part.m_partNameIndices[p_state.hatVariantIndex]; + if (partNameIndex == 0xff) { + return; + } + + LegoROI* childROI = FindChildROI(p_rootROI, g_actorLODs[c_infohatLOD].m_name); + + if (childROI != NULL) { + char lodName[256]; + + ViewLODList* lodList = GetViewLODListManager()->Lookup(part.m_partName[partNameIndex]); + MxS32 lodSize = lodList->Size(); + sprintf(lodName, "%s_cv%u", p_rootROI->GetName(), s_variantCounter++); + ViewLODList* dupLodList = GetViewLODListManager()->Create(lodName, lodSize); + + Tgl::Renderer* renderer = VideoManager()->GetRenderer(); + LegoFloat red, green, blue, alpha; + LegoROI::GetRGBAColor( + part.m_names[part.m_nameIndices[p_state.colorIndices[c_infohatPart]]], + red, + green, + blue, + alpha + ); + + for (MxS32 i = 0; i < lodSize; i++) { + LegoLOD* lod = (LegoLOD*) (*lodList)[i]; + LegoLOD* clone = lod->Clone(renderer); + clone->SetColor(red, green, blue, alpha); + dupLodList->PushBack(clone); + } + + lodList->Release(); + lodList = dupLodList; + + if (childROI->GetLodLevel() >= 0) { + VideoManager()->Get3DManager()->GetLego3DView()->GetViewManager()->RemoveROIDetailFromScene(childROI); + } + + childROI->SetLODList(lodList); + lodList->Release(); + } +} + +void CharacterCustomizer::PlayClickSound(LegoROI* p_roi, const CustomizeState& p_state, bool p_basedOnMood) +{ + MxU32 objectId = p_basedOnMood ? (p_state.mood + g_characterSoundIdMoodOffset) + : (p_state.sound + g_characterSoundIdOffset); + + if (objectId) { + MxDSAction action; + action.SetAtomId(MxAtomId(LegoCharacterManager::GetCustomizeAnimFile(), e_lowerCase2)); + action.SetObjectId(objectId); + + const char* name = p_roi->GetName(); + action.AppendExtra(SDL_strlen(name) + 1, name); + Start(&action); + } +} + +MxU32 CharacterCustomizer::PlayClickAnimation(LegoROI* p_roi, const CustomizeState& p_state) +{ + MxU32 objectId = p_state.move + g_characterAnimationId; + + MxDSAction action; + action.SetAtomId(MxAtomId(LegoCharacterManager::GetCustomizeAnimFile(), e_lowerCase2)); + action.SetObjectId(objectId); + + char extra[1024]; + SDL_snprintf(extra, sizeof(extra), "SUBST:actor_01:%s", p_roi->GetName()); + action.AppendExtra(SDL_strlen(extra) + 1, extra); + StartActionIfInitialized(action); + + return objectId; +} + +void CharacterCustomizer::StopClickAnimation(MxU32 p_objectId) +{ + MxDSAction action; + action.SetAtomId(MxAtomId(LegoCharacterManager::GetCustomizeAnimFile(), e_lowerCase2)); + action.SetObjectId(p_objectId); + DeleteObject(action); +} diff --git a/extensions/src/multiplayer/customizestate.cpp b/extensions/src/multiplayer/customizestate.cpp new file mode 100644 index 00000000..e0e51b9e --- /dev/null +++ b/extensions/src/multiplayer/customizestate.cpp @@ -0,0 +1,88 @@ +#include "extensions/multiplayer/customizestate.h" + +#include "legoactors.h" +#include "misc.h" + +#include + +using namespace Multiplayer; + +void CustomizeState::InitFromActorInfo(uint8_t p_actorInfoIndex) +{ + if (p_actorInfoIndex >= sizeOfArray(g_actorInfoInit)) { + return; + } + + const LegoActorInfo& info = g_actorInfoInit[p_actorInfoIndex]; + + // Set the 6 independent colorable parts from actor info + colorIndices[c_infohatPart] = info.m_parts[c_infohatPart].m_nameIndex; + colorIndices[c_infogronPart] = info.m_parts[c_infogronPart].m_nameIndex; + colorIndices[c_armlftPart] = info.m_parts[c_armlftPart].m_nameIndex; + colorIndices[c_armrtPart] = info.m_parts[c_armrtPart].m_nameIndex; + colorIndices[c_leglftPart] = info.m_parts[c_leglftPart].m_nameIndex; + colorIndices[c_legrtPart] = info.m_parts[c_legrtPart].m_nameIndex; + + // Derive dependent parts (must match Unpack derivation rules) + colorIndices[c_bodyPart] = colorIndices[c_infogronPart]; + colorIndices[c_headPart] = colorIndices[c_infohatPart]; + colorIndices[c_clawlftPart] = colorIndices[c_armlftPart]; + colorIndices[c_clawrtPart] = colorIndices[c_armrtPart]; + + hatVariantIndex = info.m_parts[c_infohatPart].m_partNameIndex; + sound = (uint8_t) info.m_sound; + move = (uint8_t) info.m_move; + mood = info.m_mood; +} + +void CustomizeState::Pack(uint8_t p_out[5]) const +{ + // byte 0: hatVariantIndex(5 bits) | reserved(3 bits) + p_out[0] = (hatVariantIndex & 0x1F); + + // byte 1: sound(4 bits) | move(2 bits) | mood(2 bits) + p_out[1] = (sound & 0x0F) | ((move & 0x03) << 4) | ((mood & 0x03) << 6); + + // byte 2: infohatColor(4 bits) | infogronColor(4 bits) + p_out[2] = (colorIndices[c_infohatPart] & 0x0F) | ((colorIndices[c_infogronPart] & 0x0F) << 4); + + // byte 3: armlftColor(4 bits) | armrtColor(4 bits) + p_out[3] = (colorIndices[c_armlftPart] & 0x0F) | ((colorIndices[c_armrtPart] & 0x0F) << 4); + + // byte 4: leglftColor(4 bits) | legrtColor(4 bits) + p_out[4] = (colorIndices[c_leglftPart] & 0x0F) | ((colorIndices[c_legrtPart] & 0x0F) << 4); +} + +void CustomizeState::Unpack(const uint8_t p_in[5]) +{ + // byte 0: hatVariantIndex(5 bits) | reserved(3 bits) + hatVariantIndex = p_in[0] & 0x1F; + + // byte 1: sound(4 bits) | move(2 bits) | mood(2 bits) + sound = p_in[1] & 0x0F; + move = (p_in[1] >> 4) & 0x03; + mood = (p_in[1] >> 6) & 0x03; + + // byte 2: infohatColor(4 bits) | infogronColor(4 bits) + colorIndices[c_infohatPart] = p_in[2] & 0x0F; + colorIndices[c_infogronPart] = (p_in[2] >> 4) & 0x0F; + + // byte 3: armlftColor(4 bits) | armrtColor(4 bits) + colorIndices[c_armlftPart] = p_in[3] & 0x0F; + colorIndices[c_armrtPart] = (p_in[3] >> 4) & 0x0F; + + // byte 4: leglftColor(4 bits) | legrtColor(4 bits) + colorIndices[c_leglftPart] = p_in[4] & 0x0F; + colorIndices[c_legrtPart] = (p_in[4] >> 4) & 0x0F; + + // Derive non-independent parts + colorIndices[c_bodyPart] = colorIndices[c_infogronPart]; + colorIndices[c_headPart] = colorIndices[c_infohatPart]; + colorIndices[c_clawlftPart] = colorIndices[c_armlftPart]; + colorIndices[c_clawrtPart] = colorIndices[c_armrtPart]; +} + +bool CustomizeState::operator==(const CustomizeState& p_other) const +{ + return SDL_memcmp(this, &p_other, sizeof(CustomizeState)) == 0; +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index b0fc9860..99a8b0b8 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -1,5 +1,7 @@ #include "extensions/multiplayer/networkmanager.h" +#include "extensions/multiplayer/charactercloner.h" +#include "extensions/multiplayer/charactercustomizer.h" #include "legogamestate.h" #include "legomain.h" #include "legopathactor.h" @@ -32,9 +34,10 @@ void NetworkManager::SendMessage(const T& p_msg) NetworkManager::NetworkManager() : m_transport(nullptr), m_callbacks(nullptr), m_localPeerId(0), m_hostPeerId(0), m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localWalkAnimId(0), m_localIdleAnimId(0), - m_localDisplayActorIndex(DISPLAY_ACTOR_NONE), m_inIsleWorld(false), + m_localDisplayActorIndex(DISPLAY_ACTOR_NONE), m_localAllowRemoteCustomize(true), m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), - m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_showNameBubbles(true) + m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), + m_pendingToggleAllowCustomize(false), m_showNameBubbles(true) { } @@ -87,6 +90,14 @@ void NetworkManager::Initialize(NetworkTransport* p_transport, PlatformCallbacks m_worldSync.SetTransport(p_transport); } +void NetworkManager::HandleCreate() +{ + if (!m_registered) { + TickleManager()->RegisterClient(this, 10); + m_registered = true; + } +} + void NetworkManager::Shutdown() { if (m_transport) { @@ -133,11 +144,6 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) return; } - if (!m_registered) { - TickleManager()->RegisterClient(this, 10); - m_registered = true; - } - m_thirdPersonCamera.OnWorldEnabled(p_world); if (p_world->GetWorldId() == LegoOmni::e_act1) { @@ -147,12 +153,18 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) for (auto& [peerId, player] : m_remotePlayers) { if (player->IsSpawned()) { player->ReAddToScene(); - - if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) { - player->SetVisible(true); - player->SetNameBubbleVisible(m_showNameBubbles); + } + else { + player->Spawn(p_world); + if (player->GetROI()) { + m_roiToPlayer[player->GetROI()] = player.get(); } } + + if (player->IsSpawned() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) { + player->SetVisible(true); + player->SetNameBubbleVisible(m_showNameBubbles); + } } NotifyPlayerCountChanged(); @@ -210,6 +222,10 @@ void NetworkManager::ProcessPendingRequests() SendEmote(static_cast(emote)); } + if (m_pendingToggleAllowCustomize.exchange(false, std::memory_order_relaxed)) { + m_localAllowRemoteCustomize = !m_localAllowRemoteCustomize; + } + if (m_pendingToggleNameBubbles.exchange(false, std::memory_order_relaxed)) { m_showNameBubbles = !m_showNameBubbles; for (auto& [peerId, player] : m_remotePlayers) { @@ -302,6 +318,9 @@ void NetworkManager::BroadcastLocalState() } msg.displayActorIndex = displayIndex; + m_thirdPersonCamera.GetCustomizeState().Pack(msg.customizeData); + msg.customizeFlags = m_localAllowRemoteCustomize ? 0x01 : 0x00; + SendMessage(msg); } @@ -387,6 +406,13 @@ void NetworkManager::ProcessIncomingPackets() } break; } + case MSG_CUSTOMIZE: { + CustomizeMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_CUSTOMIZE) { + HandleCustomize(msg); + } + break; + } default: break; } @@ -413,6 +439,11 @@ RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_ RemotePlayer* ptr = player.get(); m_remotePlayers[p_peerId] = std::move(player); + + if (ptr->GetROI()) { + m_roiToPlayer[ptr->GetROI()] = ptr; + } + return ptr; } @@ -450,6 +481,9 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) // Respawn only if display actor changed (not on actorId change) if (it->second->GetDisplayActorIndex() != p_msg.displayActorIndex) { + if (it->second->GetROI()) { + m_roiToPlayer.erase(it->second->GetROI()); + } it->second->Despawn(); m_remotePlayers.erase(it); CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex); @@ -488,19 +522,19 @@ void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg) } } -void NetworkManager::SetWalkAnimation(uint8_t p_index) +void NetworkManager::SetWalkAnimation(uint8_t p_walkAnimId) { - if (p_index < g_walkAnimCount) { - m_localWalkAnimId = p_index; - m_thirdPersonCamera.SetWalkAnimId(p_index); + if (p_walkAnimId < g_walkAnimCount) { + m_localWalkAnimId = p_walkAnimId; + m_thirdPersonCamera.SetWalkAnimId(p_walkAnimId); } } -void NetworkManager::SetIdleAnimation(uint8_t p_index) +void NetworkManager::SetIdleAnimation(uint8_t p_idleAnimId) { - if (p_index < g_idleAnimCount) { - m_localIdleAnimId = p_index; - m_thirdPersonCamera.SetIdleAnimId(p_index); + if (p_idleAnimId < g_idleAnimCount) { + m_localIdleAnimId = p_idleAnimId; + m_thirdPersonCamera.SetIdleAnimId(p_idleAnimId); } } @@ -518,10 +552,10 @@ void NetworkManager::SendEmote(uint8_t p_emoteId) SendMessage(msg); } -void NetworkManager::SetDisplayActorIndex(uint8_t p_index) +void NetworkManager::SetDisplayActorIndex(uint8_t p_displayActorIndex) { - m_localDisplayActorIndex = p_index; - m_thirdPersonCamera.SetDisplayActorIndex(p_index); + m_localDisplayActorIndex = p_displayActorIndex; + m_thirdPersonCamera.SetDisplayActorIndex(p_displayActorIndex); } void NetworkManager::HandleEmote(const EmoteMsg& p_msg) @@ -537,6 +571,9 @@ void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) { auto it = m_remotePlayers.find(p_peerId); if (it != m_remotePlayers.end()) { + if (it->second->GetROI()) { + m_roiToPlayer.erase(it->second->GetROI()); + } it->second->Despawn(); m_remotePlayers.erase(it); NotifyPlayerCountChanged(); @@ -549,6 +586,7 @@ void NetworkManager::RemoveAllRemotePlayers() player->Despawn(); } m_remotePlayers.clear(); + m_roiToPlayer.clear(); NotifyPlayerCountChanged(); } @@ -571,3 +609,99 @@ void NetworkManager::NotifyPlayerCountChanged() m_callbacks->OnPlayerCountChanged(count); } +RemotePlayer* NetworkManager::FindPlayerByROI(LegoROI* roi) const +{ + auto it = m_roiToPlayer.find(roi); + if (it != m_roiToPlayer.end()) { + return it->second; + } + return nullptr; +} + +bool NetworkManager::IsClonedCharacter(const char* p_name) const +{ + // Check remote player clones + for (auto& it : m_remotePlayers) { + if (!SDL_strcasecmp(it.second->GetUniqueName(), p_name)) { + return true; + } + } + + // Check local 3rd-person display actor clone + if (m_thirdPersonCamera.GetDisplayROI() != nullptr && + !SDL_strcasecmp(m_thirdPersonCamera.GetDisplayROI()->GetName(), p_name)) { + return true; + } + + return false; +} + +void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex) +{ + CustomizeMsg msg{}; + msg.header = {MSG_CUSTOMIZE, m_localPeerId, m_sequence++}; + msg.targetPeerId = p_targetPeerId; + msg.changeType = p_changeType; + msg.partIndex = p_partIndex; + SendMessage(msg); +} + +void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) +{ + uint32_t targetPeerId = p_msg.targetPeerId; + + // Check if the target is a remote player on this client. + // Only play effects here — do NOT modify the remote player's customize state. + // State changes come exclusively through UpdateFromNetwork (from the target's + // authoritative PlayerStateMsg), which prevents flip-flop from stale state messages. + // Note: sound/mood feedback uses the old state (before the authoritative update arrives), + // so the played sound may lag one step behind. This is an accepted tradeoff. + auto it = m_remotePlayers.find(targetPeerId); + if (it != m_remotePlayers.end()) { + if (it->second->GetROI()) { + CharacterCustomizer::PlayClickSound( + it->second->GetROI(), + it->second->GetCustomizeState(), + p_msg.changeType == CHANGE_MOOD + ); + if (!it->second->IsMoving()) { + MxU32 clickAnimId = + CharacterCustomizer::PlayClickAnimation(it->second->GetROI(), it->second->GetCustomizeState()); + it->second->SetClickAnimObjectId(clickAnimId); + } + } + return; + } + + // Check if the target is the local player + if (targetPeerId == m_localPeerId) { + // Reject remote customization if not allowed + if (p_msg.header.peerId != m_localPeerId && !m_localAllowRemoteCustomize) { + return; + } + + // ApplyCustomizeChange handles null display ROI (advances state without visual) + m_thirdPersonCamera.ApplyCustomizeChange(p_msg.changeType, p_msg.partIndex); + + // Use display ROI for effects in 3rd person, native ROI in 1st person + LegoROI* effectROI = m_thirdPersonCamera.GetDisplayROI(); + if (!effectROI && UserActor()) { + effectROI = UserActor()->GetROI(); + } + + if (effectROI) { + CharacterCustomizer::PlayClickSound( + effectROI, m_thirdPersonCamera.GetCustomizeState(), p_msg.changeType == CHANGE_MOOD + ); + + // Only play click animation in 3rd person (not visible in 1st person) + if (m_thirdPersonCamera.GetDisplayROI() && !m_thirdPersonCamera.IsInVehicle()) { + MxU32 clickAnimId = CharacterCustomizer::PlayClickAnimation( + m_thirdPersonCamera.GetDisplayROI(), m_thirdPersonCamera.GetCustomizeState() + ); + m_thirdPersonCamera.SetClickAnimObjectId(clickAnimId); + } + } + } +} + diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp index e594d97e..1b78830b 100644 --- a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -50,6 +50,14 @@ extern "C" } } + EMSCRIPTEN_KEEPALIVE void mp_toggle_allow_customize() + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestToggleAllowCustomize(); + } + } + } // extern "C" #endif diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index f788b140..a0e092ac 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -1,5 +1,6 @@ #include "extensions/multiplayer/remoteplayer.h" +#include "extensions/multiplayer/charactercustomizer.h" #include "extensions/multiplayer/namebubblerenderer.h" #include "3dmanager/lego3dmanager.h" @@ -30,8 +31,9 @@ RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displ m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), 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_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_vehicleROI(nullptr), - m_currentVehicleType(VEHICLE_NONE), m_nameBubble(nullptr) + m_clickAnimObjectId(0), m_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), + m_rideVehicleROI(nullptr), m_vehicleROI(nullptr), m_currentVehicleType(VEHICLE_NONE), m_nameBubble(nullptr), + m_allowRemoteCustomize(true) { m_displayName[0] = '\0'; const char* displayName = GetDisplayActorName(); @@ -83,6 +85,10 @@ void RemotePlayer::Spawn(LegoWorld* p_isleWorld) m_spawned = true; m_visible = false; + // Initialize customize state from the display actor's info + uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex, m_actorId); + m_customizeState.InitFromActorInfo(actorInfoIndex); + // Build initial walk and idle animation caches m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); @@ -99,6 +105,7 @@ void RemotePlayer::Despawn() return; } + StopClickAnimation(); DestroyNameBubble(); ExitVehicle(); @@ -143,6 +150,7 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg) SET3(m_currentPosition, m_targetPosition); SET3(m_currentDirection, m_targetDirection); SET3(m_currentUp, m_targetUp); + m_targetSpeed = 0.0f; // No meaningful speed from first sample m_hasReceivedUpdate = true; } @@ -167,6 +175,21 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg) } } + // Update customize state from packed data + CustomizeState newState; + newState.Unpack(p_msg.customizeData); + + if (newState != m_customizeState) { + uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex, m_actorId); + m_customizeState = newState; + if (m_spawned && m_roi) { + CharacterCustomizer::ApplyFullState(m_roi, actorInfoIndex, m_customizeState); + } + } + + // Update allow remote customize flag + m_allowRemoteCustomize = (p_msg.customizeFlags & 0x01) != 0; + // Swap walk animation if changed if (p_msg.walkAnimId != m_walkAnimId && p_msg.walkAnimId < g_walkAnimCount) { m_walkAnimId = p_msg.walkAnimId; @@ -263,6 +286,8 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId) return; } + StopClickAnimation(); + m_emoteAnimCache = cache; m_emoteTime = 0.0f; m_emoteDuration = (float) cache->anim->GetDuration(); @@ -299,6 +324,7 @@ void RemotePlayer::UpdateTransform(float p_deltaTime) void RemotePlayer::UpdateAnimation(float p_deltaTime) { if (m_currentVehicleType != VEHICLE_NONE && IsLargeVehicle(m_currentVehicleType)) { + StopClickAnimation(); return; } @@ -329,10 +355,13 @@ void RemotePlayer::UpdateAnimation(float p_deltaTime) bool inVehicle = (m_currentVehicleType != VEHICLE_NONE); bool isMoving = inVehicle || m_targetSpeed > 0.01f; - // Movement interrupts emotes - if (isMoving && m_emoteActive) { - m_emoteActive = false; - m_emoteAnimCache = nullptr; + // Movement interrupts click animations and emotes + if (isMoving) { + StopClickAnimation(); + if (m_emoteActive) { + m_emoteActive = false; + m_emoteAnimCache = nullptr; + } } if (isMoving) { @@ -548,3 +577,12 @@ void RemotePlayer::SetNameBubbleVisible(bool p_visible) m_nameBubble->SetVisible(p_visible); } } + +void RemotePlayer::StopClickAnimation() +{ + if (m_clickAnimObjectId != 0) { + CharacterCustomizer::StopClickAnimation(m_clickAnimObjectId); + m_clickAnimObjectId = 0; + } +} + diff --git a/extensions/src/multiplayer/server/gameroom.ts b/extensions/src/multiplayer/server/gameroom.ts index 3acfb406..c393c7d8 100644 --- a/extensions/src/multiplayer/server/gameroom.ts +++ b/extensions/src/multiplayer/server/gameroom.ts @@ -1,5 +1,6 @@ import { HEADER_SIZE, + MSG_CUSTOMIZE, MSG_REQUEST_SNAPSHOT, MSG_WORLD_EVENT_REQUEST, MSG_WORLD_SNAPSHOT, @@ -169,6 +170,10 @@ export class GameRoom implements DurableObject { data.length >= SNAPSHOT_MIN_SIZE ) { this.sendToTarget(stamped); + } else if (msgType === MSG_CUSTOMIZE) { + // Broadcast to all including sender so the clicker sees effects + // on the target's clone on their own screen. + this.broadcast(stamped.buffer); } else { this.broadcastExcept(stamped.buffer, peerId); } diff --git a/extensions/src/multiplayer/server/protocol.ts b/extensions/src/multiplayer/server/protocol.ts index 46e4a911..d1110f28 100644 --- a/extensions/src/multiplayer/server/protocol.ts +++ b/extensions/src/multiplayer/server/protocol.ts @@ -12,6 +12,7 @@ export const MSG_HOST_ASSIGN = 4; export const MSG_REQUEST_SNAPSHOT = 5; export const MSG_WORLD_SNAPSHOT = 6; export const MSG_WORLD_EVENT_REQUEST = 8; +export const MSG_CUSTOMIZE = 10; export const MSG_ASSIGN_ID = 0xff; // AssignIdMsg: compact server-only message — type(1) + peerId(4) diff --git a/extensions/src/multiplayer/thirdpersoncamera.cpp b/extensions/src/multiplayer/thirdpersoncamera.cpp index 14ab0e78..463c9f1e 100644 --- a/extensions/src/multiplayer/thirdpersoncamera.cpp +++ b/extensions/src/multiplayer/thirdpersoncamera.cpp @@ -3,7 +3,9 @@ #include "3dmanager/lego3dmanager.h" #include "anim/legoanim.h" #include "extensions/multiplayer/charactercloner.h" +#include "extensions/multiplayer/charactercustomizer.h" #include "islepathactor.h" +#include "legogamestate.h" #include "legoanimpresenter.h" #include "legocameracontroller.h" #include "legocharactermanager.h" @@ -39,8 +41,8 @@ ThirdPersonCamera::ThirdPersonCamera() m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(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) + m_clickAnimObjectId(0), m_currentVehicleType(VEHICLE_NONE), m_rideAnim(nullptr), m_rideRoiMap(nullptr), + m_rideRoiMapSize(0), m_rideVehicleROI(nullptr) { SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName)); } @@ -254,6 +256,7 @@ void ThirdPersonCamera::Tick(float p_deltaTime) // Small vehicle with ride animation (like RemotePlayer) if (m_currentVehicleType != VEHICLE_NONE) { + StopClickAnimation(); if (m_rideAnim && m_rideRoiMap) { LegoPathActor* actor = UserActor(); if (!actor || !actor->GetROI()) { @@ -331,10 +334,13 @@ void ThirdPersonCamera::Tick(float p_deltaTime) float speed = userActor->GetWorldSpeed(); bool isMoving = fabsf(speed) > 0.01f; - // Movement interrupts emotes - if (isMoving && m_emoteActive) { - m_emoteActive = false; - m_emoteAnimCache = nullptr; + // Movement interrupts click animations and emotes + if (isMoving) { + StopClickAnimation(); + if (m_emoteActive) { + m_emoteActive = false; + m_emoteAnimCache = nullptr; + } } if (isMoving) { @@ -416,28 +422,28 @@ void ThirdPersonCamera::Tick(float p_deltaTime) } } -void ThirdPersonCamera::SetWalkAnimId(uint8_t p_id) +void ThirdPersonCamera::SetWalkAnimId(uint8_t p_walkAnimId) { - if (p_id >= g_walkAnimCount) { + if (p_walkAnimId >= g_walkAnimCount) { return; } - if (p_id != m_walkAnimId) { - m_walkAnimId = p_id; + if (p_walkAnimId != m_walkAnimId) { + m_walkAnimId = p_walkAnimId; if (m_active) { m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); } } } -void ThirdPersonCamera::SetIdleAnimId(uint8_t p_id) +void ThirdPersonCamera::SetIdleAnimId(uint8_t p_idleAnimId) { - if (p_id >= g_idleAnimCount) { + if (p_idleAnimId >= g_idleAnimCount) { return; } - if (p_id != m_idleAnimId) { - m_idleAnimId = p_id; + if (p_idleAnimId != m_idleAnimId) { + m_idleAnimId = p_idleAnimId; if (m_active) { m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); } @@ -460,6 +466,8 @@ void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId) return; } + StopClickAnimation(); + m_emoteAnimCache = cache; m_emoteTime = 0.0f; m_emoteDuration = (float) cache->anim->GetDuration(); @@ -470,6 +478,24 @@ void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId) m_emoteParentTransform = m_playerROI->GetLocal2World(); } +void ThirdPersonCamera::ApplyCustomizeChange(uint8_t p_changeType, uint8_t p_partIndex) +{ + uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex( + m_displayActorIndex, + GameState() ? GameState()->GetActorId() : 0 + ); + + CharacterCustomizer::ApplyChange(m_displayROI, actorInfoIndex, m_customizeState, p_changeType, p_partIndex); +} + +void ThirdPersonCamera::StopClickAnimation() +{ + if (m_clickAnimObjectId != 0) { + CharacterCustomizer::StopClickAnimation(m_clickAnimObjectId); + m_clickAnimObjectId = 0; + } +} + void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world) { if (!m_enabled || !p_world) { @@ -566,9 +592,12 @@ void ThirdPersonCamera::BuildRideAnimation(int8_t p_vehicleType) m_animTime = 0.0f; } -void ThirdPersonCamera::SetDisplayActorIndex(uint8_t p_index) +void ThirdPersonCamera::SetDisplayActorIndex(uint8_t p_displayActorIndex) { - m_displayActorIndex = p_index; + if (m_displayActorIndex != p_displayActorIndex) { + m_customizeState.InitFromActorInfo(p_displayActorIndex); + } + m_displayActorIndex = p_displayActorIndex; } bool ThirdPersonCamera::EnsureDisplayROI() @@ -598,10 +627,17 @@ void ThirdPersonCamera::CreateDisplayClone() } SDL_snprintf(m_displayUniqueName, sizeof(m_displayUniqueName), "tp_display"); m_displayROI = CharacterCloner::Clone(charMgr, m_displayUniqueName, actorName); + + if (m_displayROI) { + // Reapply existing customize state to the new clone (preserves state across world transitions). + // The state is only reset to defaults when the display actor index changes (SetDisplayActorIndex). + CharacterCustomizer::ApplyFullState(m_displayROI, m_displayActorIndex, m_customizeState); + } } void ThirdPersonCamera::DestroyDisplayClone() { + StopClickAnimation(); if (m_displayROI) { if (m_playerROI == m_displayROI) { m_playerROI = nullptr; diff --git a/tools/ncc/skip.yml b/tools/ncc/skip.yml index e18250e9..271a4271 100644 --- a/tools/ncc/skip.yml +++ b/tools/ncc/skip.yml @@ -78,4 +78,5 @@ SDL_KeyboardID_v: "SDL-based name" SDL_MouseID_v: "SDL-based name" SDL_JoystickID_v: "SDL-based name" SDL_TouchID_v: "SDL-based name" -Load: "Not a variable but function name" \ No newline at end of file +Load: "Not a variable but function name" +HandleCreate: "Not a variable but function name" \ No newline at end of file