Character customization (#8)

* WIP: Add character customization to multiplayer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Refine character customization: fix message buffering, DRY up code, request-based model

- Register NetworkManager with TickleManager via HandleCreate hook in
  LegoOmni::Create(), so packets are processed continuously instead of
  buffering between Connect() and OnWorldEnabled()
- Spawn unspawned remote players in OnWorldEnabled() (created before
  world was ready)
- Switch to request-based customization: HandleROIClick sends
  MSG_CUSTOMIZE to server, server echoes to all peers, HandleCustomize
  applies state and plays effects
- DRY up SwitchVariant to delegate LOD cloning to ApplyHatVariant
- Add ApplyChange helper consolidating the switch-on-changeType pattern
- Fix InitFromActorInfo to derive dependent color parts from independent
  parts (matching Unpack rules)
- Remove dead code: m_hasBeenTicked, ApplyCustomizeChange on
  RemotePlayer, m_localCustomizeState on NetworkManager
- Add null ROI checks in HandleCustomize for unspawned players
- Move MSG_CUSTOMIZE constant to shared protocol.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix

* Fix character customization bugs from code review

- BUG-1: Add bounds check in SwitchColor to prevent OOB access from
  unvalidated network input (p_partIndex could exceed array bounds)
- BUG-2: Enforce allowRemoteCustomize on receiver side in HandleCustomize
  (was only checked on sender side, byppassable by malicious client)
- BUG-3: Document stale-state sound asymmetry for remote targets
- OBS-1: Remove unused bodyVariantIndex from CustomizeState (never
  modified, wasted 3 bits per state message)
- NAME-1: Fix p_ prefix convention on ApplyCustomizeChange parameters
This commit is contained in:
foxtacles 2026-03-07 14:20:55 -08:00 committed by GitHub
parent c943d2455d
commit a9747dec11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 948 additions and 57 deletions

View File

@ -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

View File

@ -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 <stdio.h>
#include <vec.h>
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<MultiplayerExt>::Call(IsClonedCharacter, p_name).value_or(FALSE)) {
LegoExtraActor* actor = new LegoExtraActor();
actor->SetROI(character->m_roi, FALSE, FALSE);

View File

@ -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 <SDL3/SDL_log.h>
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<MultiplayerExt>::Call(HandleROIClick, roi, p_param).value_or(FALSE)) {
return TRUE;
}
}
}

View File

@ -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<MultiplayerExt>::Call(HandleCreate);
result = SUCCESS;
}
else {

View File

@ -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<std::string, std::string> 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

View File

@ -0,0 +1,40 @@
#pragma once
#include "mxtypes.h"
#include <cstdint>
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

View File

@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
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

View File

@ -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<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers;
std::map<LegoROI*, RemotePlayer*> 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<int> m_pendingWalkAnim;
std::atomic<int> m_pendingIdleAnim;
std::atomic<int> m_pendingEmote;
std::atomic<bool> m_pendingToggleAllowCustomize;
bool m_showNameBubbles;

View File

@ -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)

View File

@ -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<std::string, AnimCache> m_animCacheMap;
@ -106,6 +119,9 @@ class RemotePlayer {
int8_t m_currentVehicleType;
NameBubbleRenderer* m_nameBubble;
CustomizeState m_customizeState;
bool m_allowRemoteCustomize;
};
} // namespace Multiplayer

View File

@ -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;

View File

@ -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();

View File

@ -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 <SDL3/SDL_stdinc.h>
@ -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<uint8_t>(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()) {

View File

@ -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 <SDL3/SDL_stdinc.h>
#include <cstdio>
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);
}

View File

@ -0,0 +1,88 @@
#include "extensions/multiplayer/customizestate.h"
#include "legoactors.h"
#include "misc.h"
#include <SDL3/SDL_stdinc.h>
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;
}

View File

@ -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,13 +153,19 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
for (auto& [peerId, player] : m_remotePlayers) {
if (player->IsSpawned()) {
player->ReAddToScene();
}
else {
player->Spawn(p_world);
if (player->GetROI()) {
m_roiToPlayer[player->GetROI()] = player.get();
}
}
if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
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<uint8_t>(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);
}
}
}
}

View File

@ -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

View File

@ -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,11 +355,14 @@ 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) {
// Movement interrupts click animations and emotes
if (isMoving) {
StopClickAnimation();
if (m_emoteActive) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
}
}
if (isMoving) {
// Walking / riding
@ -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;
}
}

View File

@ -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);
}

View File

@ -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)

View File

@ -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,11 +334,14 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
float speed = userActor->GetWorldSpeed();
bool isMoving = fabsf(speed) > 0.01f;
// Movement interrupts emotes
if (isMoving && m_emoteActive) {
// Movement interrupts click animations and emotes
if (isMoving) {
StopClickAnimation();
if (m_emoteActive) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
}
}
if (isMoving) {
if (!walkAnim || !walkRoiMap) {
@ -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;

View File

@ -79,3 +79,4 @@ 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"
HandleCreate: "Not a variable but function name"