mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 10:33:57 +00:00
Implement multiplayer MVP: WebSocket networking, remote player rendering, vehicle support
- WebSocket relay server (Cloudflare Worker + Durable Object) - Remote player character cloning with walk/idle/ride animations - Vehicle support for remote players - INI config for relay URL - Extension hook for world transition ROI management
This commit is contained in:
parent
03cb40190a
commit
5c8a2ffd3b
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,8 +14,7 @@ VENV/
|
|||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
local.properties
|
local.properties
|
||||||
/build/
|
/build*/
|
||||||
/build_debug/
|
|
||||||
/legobin/
|
/legobin/
|
||||||
*.swp
|
*.swp
|
||||||
LEGO1PROGRESS.*
|
LEGO1PROGRESS.*
|
||||||
|
|||||||
@ -531,6 +531,10 @@ if (ISLE_EXTENSIONS)
|
|||||||
extensions/src/extensions.cpp
|
extensions/src/extensions.cpp
|
||||||
extensions/src/siloader.cpp
|
extensions/src/siloader.cpp
|
||||||
extensions/src/textureloader.cpp
|
extensions/src/textureloader.cpp
|
||||||
|
extensions/src/multiplayer.cpp
|
||||||
|
extensions/src/multiplayer/networkmanager.cpp
|
||||||
|
extensions/src/multiplayer/remoteplayer.cpp
|
||||||
|
extensions/src/multiplayer/websockettransport.cpp
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,11 @@ void Emscripten_SetupDefaultConfigOverrides(dictionary* p_dictionary)
|
|||||||
iniparser_set(p_dictionary, "isle:Full Screen", "false");
|
iniparser_set(p_dictionary, "isle:Full Screen", "false");
|
||||||
iniparser_set(p_dictionary, "isle:Flip Surfaces", "true");
|
iniparser_set(p_dictionary, "isle:Flip Surfaces", "true");
|
||||||
|
|
||||||
|
iniparser_set(p_dictionary, "extensions", NULL);
|
||||||
|
iniparser_set(p_dictionary, "extensions:multiplayer", "true");
|
||||||
|
iniparser_set(p_dictionary, "multiplayer", NULL);
|
||||||
|
iniparser_set(p_dictionary, "multiplayer:relay url", "ws://localhost:8787");
|
||||||
|
|
||||||
// Emscripten-only for now
|
// Emscripten-only for now
|
||||||
Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true));
|
Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true));
|
||||||
Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true));
|
Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true));
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class LegoCharacterManager {
|
|||||||
MxU32 GetSoundId(LegoROI* p_roi, MxBool p_basedOnMood);
|
MxU32 GetSoundId(LegoROI* p_roi, MxBool p_basedOnMood);
|
||||||
MxU8 GetMood(LegoROI* p_roi);
|
MxU8 GetMood(LegoROI* p_roi);
|
||||||
LegoROI* CreateAutoROI(const char* p_name, const char* p_lodName, MxBool p_createEntity);
|
LegoROI* CreateAutoROI(const char* p_name, const char* p_lodName, MxBool p_createEntity);
|
||||||
|
LegoROI* CreateCharacterClone(const char* p_uniqueName, const char* p_characterType);
|
||||||
MxResult UpdateBoundingSphereAndBox(LegoROI* p_roi);
|
MxResult UpdateBoundingSphereAndBox(LegoROI* p_roi);
|
||||||
LegoROI* FUN_10085a80(const char* p_name, const char* p_lodName, MxBool p_createEntity);
|
LegoROI* FUN_10085a80(const char* p_name, const char* p_lodName, MxBool p_createEntity);
|
||||||
|
|
||||||
|
|||||||
@ -1110,3 +1110,146 @@ void CustomizeAnimFileVariable::SetValue(const char* p_value)
|
|||||||
BuildingManager()->SetCustomizeAnimFile(p_value);
|
BuildingManager()->SetCustomizeAnimFile(p_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creates an independent multi-part character ROI clone for multiplayer.
|
||||||
|
// Same construction logic as CreateActorROI but with a unique name and
|
||||||
|
// no side effects on g_actorInfo[].m_roi.
|
||||||
|
LegoROI* LegoCharacterManager::CreateCharacterClone(const char* p_uniqueName, const char* p_characterType)
|
||||||
|
{
|
||||||
|
MxBool success = FALSE;
|
||||||
|
LegoROI* roi = NULL;
|
||||||
|
BoundingSphere boundingSphere;
|
||||||
|
BoundingBox boundingBox;
|
||||||
|
MxMatrix mat;
|
||||||
|
CompoundObject* comp;
|
||||||
|
MxS32 i;
|
||||||
|
|
||||||
|
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
|
||||||
|
ViewLODListManager* lodManager = GetViewLODListManager();
|
||||||
|
LegoTextureContainer* textureContainer = TextureContainer();
|
||||||
|
LegoActorInfo* info = GetActorInfo(p_characterType);
|
||||||
|
|
||||||
|
if (info == NULL) {
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
|
||||||
|
roi = new LegoROI(renderer);
|
||||||
|
roi->SetName(p_uniqueName);
|
||||||
|
|
||||||
|
boundingSphere.Center()[0] = g_actorLODs[c_topLOD].m_boundingSphere[0];
|
||||||
|
boundingSphere.Center()[1] = g_actorLODs[c_topLOD].m_boundingSphere[1];
|
||||||
|
boundingSphere.Center()[2] = g_actorLODs[c_topLOD].m_boundingSphere[2];
|
||||||
|
boundingSphere.Radius() = g_actorLODs[c_topLOD].m_boundingSphere[3];
|
||||||
|
roi->SetBoundingSphere(boundingSphere);
|
||||||
|
|
||||||
|
boundingBox.Min()[0] = g_actorLODs[c_topLOD].m_boundingBox[0];
|
||||||
|
boundingBox.Min()[1] = g_actorLODs[c_topLOD].m_boundingBox[1];
|
||||||
|
boundingBox.Min()[2] = g_actorLODs[c_topLOD].m_boundingBox[2];
|
||||||
|
boundingBox.Max()[0] = g_actorLODs[c_topLOD].m_boundingBox[3];
|
||||||
|
boundingBox.Max()[1] = g_actorLODs[c_topLOD].m_boundingBox[4];
|
||||||
|
boundingBox.Max()[2] = g_actorLODs[c_topLOD].m_boundingBox[5];
|
||||||
|
roi->SetBoundingBox(boundingBox);
|
||||||
|
|
||||||
|
comp = new CompoundObject();
|
||||||
|
roi->SetComp(comp);
|
||||||
|
|
||||||
|
for (i = 0; i < sizeOfArray(g_actorLODs) - 1; i++) {
|
||||||
|
char lodName[256];
|
||||||
|
LegoActorInfo::Part& part = info->m_parts[i];
|
||||||
|
|
||||||
|
const char* parentName;
|
||||||
|
if (i == 0 || i == 1) {
|
||||||
|
parentName = part.m_partName[part.m_partNameIndices[part.m_partNameIndex]];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parentName = g_actorLODs[i + 1].m_parentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewLODList* lodList = lodManager->Lookup(parentName);
|
||||||
|
MxS32 lodSize = lodList->Size();
|
||||||
|
sprintf(lodName, "%s%d", p_uniqueName, i);
|
||||||
|
ViewLODList* dupLodList = lodManager->Create(lodName, lodSize);
|
||||||
|
|
||||||
|
for (MxS32 j = 0; j < lodSize; j++) {
|
||||||
|
LegoLOD* lod = (LegoLOD*) (*lodList)[j];
|
||||||
|
LegoLOD* clone = lod->Clone(renderer);
|
||||||
|
dupLodList->PushBack(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
lodList->Release();
|
||||||
|
lodList = dupLodList;
|
||||||
|
|
||||||
|
LegoROI* childROI = new LegoROI(renderer, lodList);
|
||||||
|
lodList->Release();
|
||||||
|
|
||||||
|
childROI->SetName(g_actorLODs[i + 1].m_name);
|
||||||
|
childROI->SetParentROI(roi);
|
||||||
|
|
||||||
|
BoundingSphere childBoundingSphere;
|
||||||
|
childBoundingSphere.Center()[0] = g_actorLODs[i + 1].m_boundingSphere[0];
|
||||||
|
childBoundingSphere.Center()[1] = g_actorLODs[i + 1].m_boundingSphere[1];
|
||||||
|
childBoundingSphere.Center()[2] = g_actorLODs[i + 1].m_boundingSphere[2];
|
||||||
|
childBoundingSphere.Radius() = g_actorLODs[i + 1].m_boundingSphere[3];
|
||||||
|
childROI->SetBoundingSphere(childBoundingSphere);
|
||||||
|
|
||||||
|
BoundingBox childBoundingBox;
|
||||||
|
childBoundingBox.Min()[0] = g_actorLODs[i + 1].m_boundingBox[0];
|
||||||
|
childBoundingBox.Min()[1] = g_actorLODs[i + 1].m_boundingBox[1];
|
||||||
|
childBoundingBox.Min()[2] = g_actorLODs[i + 1].m_boundingBox[2];
|
||||||
|
childBoundingBox.Max()[0] = g_actorLODs[i + 1].m_boundingBox[3];
|
||||||
|
childBoundingBox.Max()[1] = g_actorLODs[i + 1].m_boundingBox[4];
|
||||||
|
childBoundingBox.Max()[2] = g_actorLODs[i + 1].m_boundingBox[5];
|
||||||
|
childROI->SetBoundingBox(childBoundingBox);
|
||||||
|
|
||||||
|
CalcLocalTransform(
|
||||||
|
Mx3DPointFloat(g_actorLODs[i + 1].m_position),
|
||||||
|
Mx3DPointFloat(g_actorLODs[i + 1].m_direction),
|
||||||
|
Mx3DPointFloat(g_actorLODs[i + 1].m_up),
|
||||||
|
mat
|
||||||
|
);
|
||||||
|
childROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
|
||||||
|
|
||||||
|
if (g_actorLODs[i + 1].m_flags & LegoActorLOD::c_useTexture &&
|
||||||
|
(i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0)) {
|
||||||
|
|
||||||
|
LegoTextureInfo* textureInfo = textureContainer->Get(part.m_names[part.m_nameIndices[part.m_nameIndex]]);
|
||||||
|
|
||||||
|
if (textureInfo != NULL) {
|
||||||
|
childROI->SetTextureInfo(textureInfo);
|
||||||
|
childROI->SetLodColor(1.0F, 1.0F, 1.0F, 0.0F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (g_actorLODs[i + 1].m_flags & LegoActorLOD::c_useColor || (i == 0 && part.m_partNameIndices[part.m_partNameIndex] == 0)) {
|
||||||
|
LegoFloat red, green, blue, alpha;
|
||||||
|
childROI->GetRGBAColor(part.m_names[part.m_nameIndices[part.m_nameIndex]], red, green, blue, alpha);
|
||||||
|
childROI->SetLodColor(red, green, blue, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
comp->push_back(childROI);
|
||||||
|
}
|
||||||
|
|
||||||
|
CalcLocalTransform(
|
||||||
|
Mx3DPointFloat(g_actorLODs[c_topLOD].m_position),
|
||||||
|
Mx3DPointFloat(g_actorLODs[c_topLOD].m_direction),
|
||||||
|
Mx3DPointFloat(g_actorLODs[c_topLOD].m_up),
|
||||||
|
mat
|
||||||
|
);
|
||||||
|
roi->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
|
||||||
|
|
||||||
|
{
|
||||||
|
LegoCharacter* character = new LegoCharacter(roi);
|
||||||
|
char* name = new char[SDL_strlen(p_uniqueName) + 1];
|
||||||
|
SDL_strlcpy(name, p_uniqueName, SDL_strlen(p_uniqueName) + 1);
|
||||||
|
(*m_characters)[name] = character;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = TRUE;
|
||||||
|
|
||||||
|
done:
|
||||||
|
if (!success && roi != NULL) {
|
||||||
|
delete roi;
|
||||||
|
roi = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roi;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
#include "legoworld.h"
|
#include "legoworld.h"
|
||||||
|
|
||||||
#include "anim/legoanim.h"
|
#include "anim/legoanim.h"
|
||||||
|
#include "extensions/multiplayer.h"
|
||||||
#include "extensions/siloader.h"
|
#include "extensions/siloader.h"
|
||||||
#include "legoanimationmanager.h"
|
#include "legoanimationmanager.h"
|
||||||
#include "legoanimpresenter.h"
|
#include "legoanimpresenter.h"
|
||||||
@ -753,6 +754,7 @@ void LegoWorld::Enable(MxBool p_enable)
|
|||||||
#ifndef BETA10
|
#ifndef BETA10
|
||||||
SetIsWorldActive(TRUE);
|
SetIsWorldActive(TRUE);
|
||||||
#endif
|
#endif
|
||||||
|
Extensions::Extension<Extensions::MultiplayerExt>::Call(Extensions::HandleWorldEnable, this, TRUE);
|
||||||
}
|
}
|
||||||
else if (!p_enable && m_disabledObjects.size() == 0) {
|
else if (!p_enable && m_disabledObjects.size() == 0) {
|
||||||
MxPresenter* presenter;
|
MxPresenter* presenter;
|
||||||
@ -815,6 +817,7 @@ void LegoWorld::Enable(MxBool p_enable)
|
|||||||
}
|
}
|
||||||
|
|
||||||
GetViewManager()->RemoveAll(NULL);
|
GetViewManager()->RemoveAll(NULL);
|
||||||
|
Extensions::Extension<Extensions::MultiplayerExt>::Call(Extensions::HandleWorldEnable, this, FALSE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1020
docs/multiplayer-mvp-assessment.md
Normal file
1020
docs/multiplayer-mvp-assessment.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
namespace Extensions
|
namespace Extensions
|
||||||
{
|
{
|
||||||
constexpr const char* availableExtensions[] = {"extensions:texture loader", "extensions:si loader"};
|
constexpr const char* availableExtensions[] =
|
||||||
|
{"extensions:texture loader", "extensions:si loader", "extensions:multiplayer"};
|
||||||
|
|
||||||
LEGO1_EXPORT void Enable(const char* p_key, std::map<std::string, std::string> p_options);
|
LEGO1_EXPORT void Enable(const char* p_key, std::map<std::string, std::string> p_options);
|
||||||
|
|
||||||
|
|||||||
44
extensions/include/extensions/multiplayer.h
Normal file
44
extensions/include/extensions/multiplayer.h
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "extensions/extensions.h"
|
||||||
|
#include "mxtypes.h"
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class LegoWorld;
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
class NetworkManager;
|
||||||
|
class NetworkTransport;
|
||||||
|
} // namespace Multiplayer
|
||||||
|
|
||||||
|
namespace Extensions
|
||||||
|
{
|
||||||
|
|
||||||
|
class MultiplayerExt {
|
||||||
|
public:
|
||||||
|
static void Initialize();
|
||||||
|
static MxBool HandleWorldEnable(LegoWorld* p_world, MxBool p_enable);
|
||||||
|
|
||||||
|
static std::map<std::string, std::string> options;
|
||||||
|
static bool enabled;
|
||||||
|
|
||||||
|
static std::string relayUrl;
|
||||||
|
|
||||||
|
static void SetNetworkManager(Multiplayer::NetworkManager* p_mgr);
|
||||||
|
static Multiplayer::NetworkManager* GetNetworkManager();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static Multiplayer::NetworkManager* s_networkManager;
|
||||||
|
static Multiplayer::NetworkTransport* s_transport;
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef EXTENSIONS
|
||||||
|
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
|
||||||
|
#else
|
||||||
|
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}; // namespace Extensions
|
||||||
73
extensions/include/extensions/multiplayer/networkmanager.h
Normal file
73
extensions/include/extensions/multiplayer/networkmanager.h
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/networktransport.h"
|
||||||
|
#include "extensions/multiplayer/protocol.h"
|
||||||
|
#include "extensions/multiplayer/remoteplayer.h"
|
||||||
|
#include "mxcore.h"
|
||||||
|
#include "mxtypes.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class LegoWorld;
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
class NetworkManager : public MxCore {
|
||||||
|
public:
|
||||||
|
NetworkManager();
|
||||||
|
~NetworkManager() override;
|
||||||
|
|
||||||
|
MxResult Tickle() override;
|
||||||
|
|
||||||
|
const char* ClassName() const override { return "NetworkManager"; }
|
||||||
|
|
||||||
|
MxBool IsA(const char* p_name) const override
|
||||||
|
{
|
||||||
|
return !strcmp(p_name, NetworkManager::ClassName()) || MxCore::IsA(p_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Initialize(NetworkTransport* p_transport);
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
void Connect(const char* p_roomId);
|
||||||
|
void Disconnect();
|
||||||
|
bool IsConnected() const;
|
||||||
|
|
||||||
|
// Called by the Multiplayer extension on world transitions
|
||||||
|
void OnWorldEnabled(LegoWorld* p_world);
|
||||||
|
void OnWorldDisabled(LegoWorld* p_world);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void BroadcastLocalState();
|
||||||
|
void ProcessIncomingPackets();
|
||||||
|
void UpdateRemotePlayers(float p_deltaTime);
|
||||||
|
|
||||||
|
void HandleJoin(const PlayerJoinMsg& p_msg);
|
||||||
|
void HandleLeave(const PlayerLeaveMsg& p_msg);
|
||||||
|
void HandleState(const PlayerStateMsg& p_msg);
|
||||||
|
|
||||||
|
void RemoveRemotePlayer(uint32_t p_peerId);
|
||||||
|
void RemoveAllRemotePlayers();
|
||||||
|
|
||||||
|
int8_t DetectLocalVehicleType();
|
||||||
|
bool IsInIsleWorld() const;
|
||||||
|
|
||||||
|
NetworkTransport* m_transport;
|
||||||
|
std::map<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers;
|
||||||
|
|
||||||
|
uint32_t m_localPeerId;
|
||||||
|
uint32_t m_sequence;
|
||||||
|
uint32_t m_lastBroadcastTime;
|
||||||
|
uint8_t m_lastValidActorId;
|
||||||
|
bool m_inIsleWorld;
|
||||||
|
bool m_registered;
|
||||||
|
|
||||||
|
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
|
||||||
|
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
26
extensions/include/extensions/multiplayer/networktransport.h
Normal file
26
extensions/include/extensions/multiplayer/networktransport.h
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
class NetworkTransport {
|
||||||
|
public:
|
||||||
|
virtual ~NetworkTransport() = default;
|
||||||
|
|
||||||
|
virtual void Connect(const char* p_roomId) = 0;
|
||||||
|
virtual void Disconnect() = 0;
|
||||||
|
virtual bool IsConnected() const = 0;
|
||||||
|
|
||||||
|
// Send binary data to all peers via relay
|
||||||
|
virtual void Send(const uint8_t* p_data, size_t p_length) = 0;
|
||||||
|
|
||||||
|
// Drain received messages. Callback called for each message.
|
||||||
|
// Returns number of messages dequeued.
|
||||||
|
virtual size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
201
extensions/include/extensions/multiplayer/protocol.h
Normal file
201
extensions/include/extensions/multiplayer/protocol.h
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
enum MessageType : uint8_t {
|
||||||
|
MSG_JOIN = 1,
|
||||||
|
MSG_LEAVE = 2,
|
||||||
|
MSG_STATE = 3,
|
||||||
|
MSG_ASSIGN_ID = 0xFF
|
||||||
|
};
|
||||||
|
|
||||||
|
enum VehicleType : int8_t {
|
||||||
|
VEHICLE_NONE = -1,
|
||||||
|
VEHICLE_HELICOPTER = 0,
|
||||||
|
VEHICLE_JETSKI = 1,
|
||||||
|
VEHICLE_DUNEBUGGY = 2,
|
||||||
|
VEHICLE_BIKE = 3,
|
||||||
|
VEHICLE_SKATEBOARD = 4,
|
||||||
|
VEHICLE_MOTOCYCLE = 5,
|
||||||
|
VEHICLE_TOWTRACK = 6,
|
||||||
|
VEHICLE_AMBULANCE = 7,
|
||||||
|
VEHICLE_COUNT = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
|
||||||
|
struct MessageHeader {
|
||||||
|
uint8_t type;
|
||||||
|
uint32_t peerId;
|
||||||
|
uint32_t sequence;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerJoinMsg {
|
||||||
|
MessageHeader header;
|
||||||
|
uint8_t actorId;
|
||||||
|
char name[20];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerLeaveMsg {
|
||||||
|
MessageHeader header;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerStateMsg {
|
||||||
|
MessageHeader header;
|
||||||
|
uint8_t actorId;
|
||||||
|
int8_t worldId;
|
||||||
|
int8_t vehicleType;
|
||||||
|
float position[3];
|
||||||
|
float direction[3];
|
||||||
|
float up[3];
|
||||||
|
float speed;
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
static const size_t STATE_MSG_SIZE = sizeof(PlayerStateMsg);
|
||||||
|
static const size_t JOIN_MSG_SIZE = sizeof(PlayerJoinMsg);
|
||||||
|
static const size_t LEAVE_MSG_SIZE = sizeof(PlayerLeaveMsg);
|
||||||
|
static const size_t HEADER_SIZE = sizeof(MessageHeader);
|
||||||
|
|
||||||
|
// Validate actorId is a playable character (1-5, not brickster)
|
||||||
|
inline bool IsValidActorId(uint8_t p_actorId)
|
||||||
|
{
|
||||||
|
return p_actorId >= 1 && p_actorId <= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize a STATE message into a buffer. Returns bytes written.
|
||||||
|
inline size_t SerializeStateMsg(
|
||||||
|
uint8_t* p_buf,
|
||||||
|
size_t p_bufLen,
|
||||||
|
uint32_t p_peerId,
|
||||||
|
uint32_t p_sequence,
|
||||||
|
uint8_t p_actorId,
|
||||||
|
int8_t p_worldId,
|
||||||
|
int8_t p_vehicleType,
|
||||||
|
const float p_position[3],
|
||||||
|
const float p_direction[3],
|
||||||
|
const float p_up[3],
|
||||||
|
float p_speed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (p_bufLen < STATE_MSG_SIZE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerStateMsg msg;
|
||||||
|
msg.header.type = MSG_STATE;
|
||||||
|
msg.header.peerId = p_peerId;
|
||||||
|
msg.header.sequence = p_sequence;
|
||||||
|
msg.actorId = p_actorId;
|
||||||
|
msg.worldId = p_worldId;
|
||||||
|
msg.vehicleType = p_vehicleType;
|
||||||
|
SDL_memcpy(msg.position, p_position, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(msg.direction, p_direction, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(msg.up, p_up, sizeof(float) * 3);
|
||||||
|
msg.speed = p_speed;
|
||||||
|
|
||||||
|
SDL_memcpy(p_buf, &msg, STATE_MSG_SIZE);
|
||||||
|
return STATE_MSG_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize a JOIN message into a buffer. Returns bytes written.
|
||||||
|
inline size_t SerializeJoinMsg(
|
||||||
|
uint8_t* p_buf,
|
||||||
|
size_t p_bufLen,
|
||||||
|
uint32_t p_peerId,
|
||||||
|
uint32_t p_sequence,
|
||||||
|
uint8_t p_actorId,
|
||||||
|
const char* p_name
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (p_bufLen < JOIN_MSG_SIZE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerJoinMsg msg;
|
||||||
|
msg.header.type = MSG_JOIN;
|
||||||
|
msg.header.peerId = p_peerId;
|
||||||
|
msg.header.sequence = p_sequence;
|
||||||
|
msg.actorId = p_actorId;
|
||||||
|
SDL_memset(msg.name, 0, sizeof(msg.name));
|
||||||
|
if (p_name) {
|
||||||
|
SDL_strlcpy(msg.name, p_name, sizeof(msg.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_memcpy(p_buf, &msg, JOIN_MSG_SIZE);
|
||||||
|
return JOIN_MSG_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize a LEAVE message into a buffer. Returns bytes written.
|
||||||
|
inline size_t SerializeLeaveMsg(uint8_t* p_buf, size_t p_bufLen, uint32_t p_peerId, uint32_t p_sequence)
|
||||||
|
{
|
||||||
|
if (p_bufLen < LEAVE_MSG_SIZE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerLeaveMsg msg;
|
||||||
|
msg.header.type = MSG_LEAVE;
|
||||||
|
msg.header.peerId = p_peerId;
|
||||||
|
msg.header.sequence = p_sequence;
|
||||||
|
|
||||||
|
SDL_memcpy(p_buf, &msg, LEAVE_MSG_SIZE);
|
||||||
|
return LEAVE_MSG_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the message type from a buffer. Returns MSG type or 0 on error.
|
||||||
|
inline uint8_t ParseMessageType(const uint8_t* p_data, size_t p_length)
|
||||||
|
{
|
||||||
|
if (p_length < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return p_data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize a message header from a buffer.
|
||||||
|
inline bool DeserializeHeader(const uint8_t* p_data, size_t p_length, MessageHeader& p_out)
|
||||||
|
{
|
||||||
|
if (p_length < HEADER_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_memcpy(&p_out, p_data, HEADER_SIZE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize a STATE message from a buffer.
|
||||||
|
inline bool DeserializeStateMsg(const uint8_t* p_data, size_t p_length, PlayerStateMsg& p_out)
|
||||||
|
{
|
||||||
|
if (p_length < STATE_MSG_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_memcpy(&p_out, p_data, STATE_MSG_SIZE);
|
||||||
|
return p_out.header.type == MSG_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize a JOIN message from a buffer.
|
||||||
|
inline bool DeserializeJoinMsg(const uint8_t* p_data, size_t p_length, PlayerJoinMsg& p_out)
|
||||||
|
{
|
||||||
|
if (p_length < JOIN_MSG_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_memcpy(&p_out, p_data, JOIN_MSG_SIZE);
|
||||||
|
p_out.name[sizeof(p_out.name) - 1] = '\0';
|
||||||
|
return p_out.header.type == MSG_JOIN && IsValidActorId(p_out.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize a LEAVE message from a buffer.
|
||||||
|
inline bool DeserializeLeaveMsg(const uint8_t* p_data, size_t p_length, PlayerLeaveMsg& p_out)
|
||||||
|
{
|
||||||
|
if (p_length < LEAVE_MSG_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_memcpy(&p_out, p_data, LEAVE_MSG_SIZE);
|
||||||
|
return p_out.header.type == MSG_LEAVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
104
extensions/include/extensions/multiplayer/remoteplayer.h
Normal file
104
extensions/include/extensions/multiplayer/remoteplayer.h
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/protocol.h"
|
||||||
|
#include "mxgeometry/mxmatrix.h"
|
||||||
|
#include "mxtypes.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
class LegoROI;
|
||||||
|
class LegoWorld;
|
||||||
|
class LegoAnim;
|
||||||
|
class LegoTreeNode;
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
class RemotePlayer {
|
||||||
|
public:
|
||||||
|
RemotePlayer(uint32_t p_peerId, uint8_t p_actorId);
|
||||||
|
~RemotePlayer();
|
||||||
|
|
||||||
|
void Spawn(LegoWorld* p_isleWorld);
|
||||||
|
void Despawn();
|
||||||
|
void UpdateFromNetwork(const PlayerStateMsg& p_msg);
|
||||||
|
void Tick(float p_deltaTime);
|
||||||
|
|
||||||
|
// Re-add ROI to 3D scene after world transition
|
||||||
|
void ReAddToScene();
|
||||||
|
|
||||||
|
uint32_t GetPeerId() const { return m_peerId; }
|
||||||
|
uint8_t GetActorId() const { return m_actorId; }
|
||||||
|
bool IsSpawned() const { return m_spawned; }
|
||||||
|
bool IsVisible() const { return m_visible; }
|
||||||
|
int8_t GetWorldId() const { return m_targetWorldId; }
|
||||||
|
uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; }
|
||||||
|
|
||||||
|
void SetVisible(bool p_visible);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void BuildWalkROIMap(LegoWorld* p_isleWorld);
|
||||||
|
void BuildROIMap(
|
||||||
|
LegoAnim* p_anim,
|
||||||
|
LegoROI* p_rootROI,
|
||||||
|
LegoROI* p_extraROI,
|
||||||
|
LegoROI**& p_roiMap,
|
||||||
|
MxU32& p_roiMapSize
|
||||||
|
);
|
||||||
|
void UpdateTransform(float p_deltaTime);
|
||||||
|
void UpdateAnimation(float p_deltaTime);
|
||||||
|
void UpdateVehicleState();
|
||||||
|
void EnterVehicle(int8_t p_vehicleType);
|
||||||
|
void ExitVehicle();
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
uint32_t m_peerId;
|
||||||
|
uint8_t m_actorId;
|
||||||
|
char m_uniqueName[32];
|
||||||
|
|
||||||
|
// Visual
|
||||||
|
LegoROI* m_roi;
|
||||||
|
bool m_spawned;
|
||||||
|
bool m_visible;
|
||||||
|
|
||||||
|
// Network state (latest received)
|
||||||
|
float m_targetPosition[3];
|
||||||
|
float m_targetDirection[3];
|
||||||
|
float m_targetUp[3];
|
||||||
|
float m_targetSpeed;
|
||||||
|
int8_t m_targetVehicleType;
|
||||||
|
int8_t m_targetWorldId;
|
||||||
|
uint32_t m_lastUpdateTime;
|
||||||
|
bool m_hasReceivedUpdate;
|
||||||
|
|
||||||
|
// Interpolation state
|
||||||
|
float m_currentPosition[3];
|
||||||
|
float m_currentDirection[3];
|
||||||
|
float m_currentUp[3];
|
||||||
|
|
||||||
|
// Walk animation state
|
||||||
|
LegoAnim* m_walkAnim;
|
||||||
|
LegoROI** m_walkRoiMap;
|
||||||
|
MxU32 m_walkRoiMapSize;
|
||||||
|
float m_animTime;
|
||||||
|
float m_idleTime;
|
||||||
|
bool m_wasMoving;
|
||||||
|
|
||||||
|
// Idle animation state (CNs008xx - breathing/swaying)
|
||||||
|
LegoAnim* m_idleAnim;
|
||||||
|
LegoROI** m_idleRoiMap;
|
||||||
|
MxU32 m_idleRoiMapSize;
|
||||||
|
float m_idleAnimTime;
|
||||||
|
|
||||||
|
// Ride animation state (small vehicles)
|
||||||
|
LegoAnim* m_rideAnim;
|
||||||
|
LegoROI** m_rideRoiMap;
|
||||||
|
MxU32 m_rideRoiMapSize;
|
||||||
|
LegoROI* m_rideVehicleROI;
|
||||||
|
|
||||||
|
// Vehicle state
|
||||||
|
LegoROI* m_vehicleROI;
|
||||||
|
int8_t m_currentVehicleType;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/networktransport.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
class WebSocketTransport : public NetworkTransport {
|
||||||
|
public:
|
||||||
|
WebSocketTransport(const std::string& p_relayBaseUrl);
|
||||||
|
~WebSocketTransport() override;
|
||||||
|
|
||||||
|
void Connect(const char* p_roomId) override;
|
||||||
|
void Disconnect() override;
|
||||||
|
bool IsConnected() const override;
|
||||||
|
void Send(const uint8_t* p_data, size_t p_length) override;
|
||||||
|
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_relayBaseUrl;
|
||||||
|
int m_socketId;
|
||||||
|
volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics
|
||||||
|
uint8_t m_recvBuf[8192];
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
|
|
||||||
|
#endif // __EMSCRIPTEN__
|
||||||
@ -1,5 +1,6 @@
|
|||||||
#include "extensions/extensions.h"
|
#include "extensions/extensions.h"
|
||||||
|
|
||||||
|
#include "extensions/multiplayer.h"
|
||||||
#include "extensions/siloader.h"
|
#include "extensions/siloader.h"
|
||||||
#include "extensions/textureloader.h"
|
#include "extensions/textureloader.h"
|
||||||
|
|
||||||
@ -19,6 +20,11 @@ void Extensions::Enable(const char* p_key, std::map<std::string, std::string> p_
|
|||||||
SiLoader::enabled = true;
|
SiLoader::enabled = true;
|
||||||
SiLoader::Initialize();
|
SiLoader::Initialize();
|
||||||
}
|
}
|
||||||
|
else if (!SDL_strcasecmp(p_key, "extensions:multiplayer")) {
|
||||||
|
MultiplayerExt::options = std::move(p_options);
|
||||||
|
MultiplayerExt::enabled = true;
|
||||||
|
MultiplayerExt::Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
SDL_Log("Enabled extension: %s", p_key);
|
SDL_Log("Enabled extension: %s", p_key);
|
||||||
break;
|
break;
|
||||||
|
|||||||
67
extensions/src/multiplayer.cpp
Normal file
67
extensions/src/multiplayer.cpp
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
#include "extensions/multiplayer.h"
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/networkmanager.h"
|
||||||
|
#include "extensions/multiplayer/networktransport.h"
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
#include "extensions/multiplayer/websockettransport.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <SDL3/SDL_log.h>
|
||||||
|
|
||||||
|
using namespace Extensions;
|
||||||
|
|
||||||
|
std::map<std::string, std::string> MultiplayerExt::options;
|
||||||
|
bool MultiplayerExt::enabled = false;
|
||||||
|
std::string MultiplayerExt::relayUrl;
|
||||||
|
Multiplayer::NetworkManager* MultiplayerExt::s_networkManager = nullptr;
|
||||||
|
Multiplayer::NetworkTransport* MultiplayerExt::s_transport = nullptr;
|
||||||
|
|
||||||
|
void MultiplayerExt::Initialize()
|
||||||
|
{
|
||||||
|
relayUrl = options["multiplayer:relay url"];
|
||||||
|
|
||||||
|
if (relayUrl.empty()) {
|
||||||
|
SDL_Log("Multiplayer: no relay url configured, multiplayer will not connect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
s_transport = new Multiplayer::WebSocketTransport(relayUrl);
|
||||||
|
|
||||||
|
s_networkManager = new Multiplayer::NetworkManager();
|
||||||
|
s_networkManager->Initialize(s_transport);
|
||||||
|
|
||||||
|
// Auto-connect to default room for MVP
|
||||||
|
s_networkManager->Connect("default");
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: initialized with relay url %s", relayUrl.c_str());
|
||||||
|
#else
|
||||||
|
SDL_Log("Multiplayer: no transport available for this platform yet");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
MxBool MultiplayerExt::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable)
|
||||||
|
{
|
||||||
|
if (!s_networkManager) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_enable) {
|
||||||
|
s_networkManager->OnWorldEnabled(p_world);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
s_networkManager->OnWorldDisabled(p_world);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_mgr)
|
||||||
|
{
|
||||||
|
s_networkManager = p_mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager()
|
||||||
|
{
|
||||||
|
return s_networkManager;
|
||||||
|
}
|
||||||
434
extensions/src/multiplayer/networkmanager.cpp
Normal file
434
extensions/src/multiplayer/networkmanager.cpp
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
#include "extensions/multiplayer/networkmanager.h"
|
||||||
|
|
||||||
|
#include "legomain.h"
|
||||||
|
#include "legopathactor.h"
|
||||||
|
#include "legoworld.h"
|
||||||
|
#include "misc.h"
|
||||||
|
#include "mxmisc.h"
|
||||||
|
#include "mxticklemanager.h"
|
||||||
|
#include "roi/legoroi.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_log.h>
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
|
#include <SDL3/SDL_timer.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace Multiplayer;
|
||||||
|
|
||||||
|
NetworkManager::NetworkManager()
|
||||||
|
: m_transport(nullptr), m_localPeerId(0), m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0),
|
||||||
|
m_inIsleWorld(false), m_registered(false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkManager::~NetworkManager()
|
||||||
|
{
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
MxResult NetworkManager::Tickle()
|
||||||
|
{
|
||||||
|
if (!m_transport) {
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t now = SDL_GetTicks();
|
||||||
|
|
||||||
|
// Broadcast BEFORE receiving: the Send proxy call gives the main thread a
|
||||||
|
// chance to process incoming WebSocket onmessage events before we drain
|
||||||
|
// the queue with Receive.
|
||||||
|
if (m_transport->IsConnected() && (now - m_lastBroadcastTime) >= BROADCAST_INTERVAL_MS) {
|
||||||
|
BroadcastLocalState();
|
||||||
|
m_lastBroadcastTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessIncomingPackets();
|
||||||
|
UpdateRemotePlayers(0.016f);
|
||||||
|
|
||||||
|
// Timeout check - remove stale remote players.
|
||||||
|
// Re-read time because ProcessIncomingPackets updates player timestamps
|
||||||
|
// via SDL_GetTicks(), which may be newer than the 'now' captured above.
|
||||||
|
// Using the stale 'now' would cause unsigned underflow (now < lastUpdate).
|
||||||
|
uint32_t timeoutNow = SDL_GetTicks();
|
||||||
|
std::vector<uint32_t> timedOut;
|
||||||
|
for (auto& [peerId, player] : m_remotePlayers) {
|
||||||
|
uint32_t lastUpdate = player->GetLastUpdateTime();
|
||||||
|
if (timeoutNow >= lastUpdate && (timeoutNow - lastUpdate) > TIMEOUT_MS) {
|
||||||
|
SDL_Log("Multiplayer: peer %u timed out", peerId);
|
||||||
|
timedOut.push_back(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uint32_t peerId : timedOut) {
|
||||||
|
RemoveRemotePlayer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::Initialize(NetworkTransport* p_transport)
|
||||||
|
{
|
||||||
|
m_transport = p_transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::Shutdown()
|
||||||
|
{
|
||||||
|
if (m_transport) {
|
||||||
|
Disconnect();
|
||||||
|
if (m_registered) {
|
||||||
|
TickleManager()->UnregisterClient(this);
|
||||||
|
m_registered = false;
|
||||||
|
}
|
||||||
|
m_transport = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveAllRemotePlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::Connect(const char* p_roomId)
|
||||||
|
{
|
||||||
|
if (m_transport) {
|
||||||
|
m_transport->Connect(p_roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::Disconnect()
|
||||||
|
{
|
||||||
|
if (m_transport) {
|
||||||
|
m_transport->Disconnect();
|
||||||
|
}
|
||||||
|
RemoveAllRemotePlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetworkManager::IsConnected() const
|
||||||
|
{
|
||||||
|
return m_transport && m_transport->IsConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
|
||||||
|
{
|
||||||
|
if (!p_world) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: OnWorldEnabled worldId=%d (e_act1=%d)", p_world->GetWorldId(), LegoOmni::e_act1);
|
||||||
|
|
||||||
|
// Register with tickle manager on first world enable (engine is now initialized)
|
||||||
|
if (!m_registered) {
|
||||||
|
TickleManager()->RegisterClient(this, 10);
|
||||||
|
m_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_world->GetWorldId() == LegoOmni::e_act1) {
|
||||||
|
m_inIsleWorld = true;
|
||||||
|
|
||||||
|
// Re-add all remote player ROIs to the 3D scene
|
||||||
|
for (auto& [peerId, player] : m_remotePlayers) {
|
||||||
|
if (player->IsSpawned()) {
|
||||||
|
player->ReAddToScene();
|
||||||
|
|
||||||
|
// Only show if the remote player is also in ISLE
|
||||||
|
if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
|
||||||
|
player->SetVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: ISLE world enabled, re-added %zu remote players", m_remotePlayers.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::OnWorldDisabled(LegoWorld* p_world)
|
||||||
|
{
|
||||||
|
if (!p_world) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_world->GetWorldId() == LegoOmni::e_act1) {
|
||||||
|
m_inIsleWorld = false;
|
||||||
|
for (auto& [peerId, player] : m_remotePlayers) {
|
||||||
|
player->SetVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::BroadcastLocalState()
|
||||||
|
{
|
||||||
|
if (!m_transport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoPathActor* userActor = UserActor();
|
||||||
|
LegoWorld* currentWorld = CurrentWorld();
|
||||||
|
|
||||||
|
if (!userActor || !currentWorld) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoROI* roi = userActor->GetROI();
|
||||||
|
if (!roi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float* pos = roi->GetWorldPosition();
|
||||||
|
const float* dir = roi->GetWorldDirection();
|
||||||
|
const float* up = roi->GetWorldUp();
|
||||||
|
float speed = userActor->GetWorldSpeed();
|
||||||
|
|
||||||
|
uint8_t actorId = static_cast<LegoActor*>(userActor)->GetActorId();
|
||||||
|
if (IsValidActorId(actorId)) {
|
||||||
|
m_lastValidActorId = actorId;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
actorId = m_lastValidActorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't broadcast if we haven't seen a valid character yet
|
||||||
|
if (!IsValidActorId(actorId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int8_t worldId = (int8_t) currentWorld->GetWorldId();
|
||||||
|
int8_t vehicleType = DetectLocalVehicleType();
|
||||||
|
|
||||||
|
// Log first broadcast per session for debugging
|
||||||
|
static bool firstBroadcast = true;
|
||||||
|
if (firstBroadcast) {
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: first broadcast actorId=%u worldId=%d vehicleType=%d pos=(%.1f,%.1f,%.1f)",
|
||||||
|
actorId,
|
||||||
|
worldId,
|
||||||
|
vehicleType,
|
||||||
|
pos[0],
|
||||||
|
pos[1],
|
||||||
|
pos[2]
|
||||||
|
);
|
||||||
|
firstBroadcast = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buf[64];
|
||||||
|
size_t len = SerializeStateMsg(
|
||||||
|
buf,
|
||||||
|
sizeof(buf),
|
||||||
|
m_localPeerId,
|
||||||
|
m_sequence++,
|
||||||
|
actorId,
|
||||||
|
worldId,
|
||||||
|
vehicleType,
|
||||||
|
pos,
|
||||||
|
dir,
|
||||||
|
up,
|
||||||
|
speed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (len > 0) {
|
||||||
|
m_transport->Send(buf, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::ProcessIncomingPackets()
|
||||||
|
{
|
||||||
|
if (!m_transport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_transport->Receive([this](const uint8_t* data, size_t length) {
|
||||||
|
uint8_t msgType = ParseMessageType(data, length);
|
||||||
|
|
||||||
|
switch (msgType) {
|
||||||
|
case MSG_ASSIGN_ID: {
|
||||||
|
if (length >= 5) {
|
||||||
|
uint32_t assignedId;
|
||||||
|
SDL_memcpy(&assignedId, data + 1, sizeof(uint32_t));
|
||||||
|
m_localPeerId = assignedId;
|
||||||
|
SDL_Log("Multiplayer: assigned peer ID %u", m_localPeerId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MSG_JOIN: {
|
||||||
|
PlayerJoinMsg msg;
|
||||||
|
if (DeserializeJoinMsg(data, length, msg)) {
|
||||||
|
HandleJoin(msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MSG_LEAVE: {
|
||||||
|
PlayerLeaveMsg msg;
|
||||||
|
if (DeserializeLeaveMsg(data, length, msg)) {
|
||||||
|
HandleLeave(msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MSG_STATE: {
|
||||||
|
PlayerStateMsg msg;
|
||||||
|
if (DeserializeStateMsg(data, length, msg)) {
|
||||||
|
HandleState(msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
SDL_Log("Multiplayer: unknown message type %u (len=%zu)", msgType, length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::UpdateRemotePlayers(float p_deltaTime)
|
||||||
|
{
|
||||||
|
for (auto& [peerId, player] : m_remotePlayers) {
|
||||||
|
player->Tick(p_deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::HandleJoin(const PlayerJoinMsg& p_msg)
|
||||||
|
{
|
||||||
|
uint32_t peerId = p_msg.header.peerId;
|
||||||
|
|
||||||
|
if (m_remotePlayers.count(peerId)) {
|
||||||
|
return; // Already known
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: peer %u joined as actor %d (%s)", peerId, p_msg.actorId, p_msg.name);
|
||||||
|
|
||||||
|
auto player = std::make_unique<RemotePlayer>(peerId, p_msg.actorId);
|
||||||
|
|
||||||
|
// Spawn in current world if we're in ISLE
|
||||||
|
if (m_inIsleWorld) {
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (world && world->GetWorldId() == LegoOmni::e_act1) {
|
||||||
|
player->Spawn(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remotePlayers[peerId] = std::move(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::HandleLeave(const PlayerLeaveMsg& p_msg)
|
||||||
|
{
|
||||||
|
RemoveRemotePlayer(p_msg.header.peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::HandleState(const PlayerStateMsg& p_msg)
|
||||||
|
{
|
||||||
|
uint32_t peerId = p_msg.header.peerId;
|
||||||
|
|
||||||
|
auto it = m_remotePlayers.find(peerId);
|
||||||
|
if (it == m_remotePlayers.end()) {
|
||||||
|
// Auto-create remote player on first STATE if we haven't seen a JOIN
|
||||||
|
if (!IsValidActorId(p_msg.actorId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: new remote peer %u (actor %u)", peerId, p_msg.actorId);
|
||||||
|
auto player = std::make_unique<RemotePlayer>(peerId, p_msg.actorId);
|
||||||
|
|
||||||
|
if (m_inIsleWorld) {
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (world && world->GetWorldId() == LegoOmni::e_act1) {
|
||||||
|
player->Spawn(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remotePlayers[peerId] = std::move(player);
|
||||||
|
it = m_remotePlayers.find(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle actor change (e.g., Pepper -> Nick): despawn and respawn with new actor
|
||||||
|
if (IsValidActorId(p_msg.actorId) && it->second->GetActorId() != p_msg.actorId) {
|
||||||
|
SDL_Log("Multiplayer: peer %u changed actor %u -> %u", peerId, it->second->GetActorId(), p_msg.actorId);
|
||||||
|
it->second->Despawn();
|
||||||
|
auto player = std::make_unique<RemotePlayer>(peerId, p_msg.actorId);
|
||||||
|
|
||||||
|
if (m_inIsleWorld) {
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (world && world->GetWorldId() == LegoOmni::e_act1) {
|
||||||
|
player->Spawn(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remotePlayers[peerId] = std::move(player);
|
||||||
|
it = m_remotePlayers.find(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
it->second->UpdateFromNetwork(p_msg);
|
||||||
|
|
||||||
|
// Handle visibility based on worldId
|
||||||
|
bool bothInIsle = m_inIsleWorld && (p_msg.worldId == (int8_t) LegoOmni::e_act1);
|
||||||
|
|
||||||
|
if (it->second->IsSpawned()) {
|
||||||
|
bool wasVisible = it->second->IsVisible();
|
||||||
|
it->second->SetVisible(bothInIsle);
|
||||||
|
if (wasVisible != bothInIsle) {
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: peer %u visibility %d->%d (inIsle=%d, msgWorld=%d, e_act1=%d, spawned=%d)",
|
||||||
|
peerId,
|
||||||
|
wasVisible,
|
||||||
|
bothInIsle,
|
||||||
|
m_inIsleWorld,
|
||||||
|
p_msg.worldId,
|
||||||
|
(int8_t) LegoOmni::e_act1,
|
||||||
|
it->second->IsSpawned()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
SDL_Log("Multiplayer: peer %u not spawned, skipping visibility (inIsle=%d)", peerId, m_inIsleWorld);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId)
|
||||||
|
{
|
||||||
|
auto it = m_remotePlayers.find(p_peerId);
|
||||||
|
if (it != m_remotePlayers.end()) {
|
||||||
|
SDL_Log("Multiplayer: peer %u removed", p_peerId);
|
||||||
|
it->second->Despawn();
|
||||||
|
m_remotePlayers.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::RemoveAllRemotePlayers()
|
||||||
|
{
|
||||||
|
for (auto& [peerId, player] : m_remotePlayers) {
|
||||||
|
player->Despawn();
|
||||||
|
}
|
||||||
|
m_remotePlayers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
int8_t NetworkManager::DetectLocalVehicleType()
|
||||||
|
{
|
||||||
|
LegoPathActor* actor = UserActor();
|
||||||
|
if (!actor) {
|
||||||
|
return VEHICLE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor->IsA("Helicopter")) {
|
||||||
|
return VEHICLE_HELICOPTER;
|
||||||
|
}
|
||||||
|
if (actor->IsA("Jetski")) {
|
||||||
|
return VEHICLE_JETSKI;
|
||||||
|
}
|
||||||
|
if (actor->IsA("DuneBuggy")) {
|
||||||
|
return VEHICLE_DUNEBUGGY;
|
||||||
|
}
|
||||||
|
if (actor->IsA("Bike")) {
|
||||||
|
return VEHICLE_BIKE;
|
||||||
|
}
|
||||||
|
if (actor->IsA("SkateBoard")) {
|
||||||
|
return VEHICLE_SKATEBOARD;
|
||||||
|
}
|
||||||
|
if (actor->IsA("Motocycle")) {
|
||||||
|
return VEHICLE_MOTOCYCLE;
|
||||||
|
}
|
||||||
|
if (actor->IsA("TowTrack")) {
|
||||||
|
return VEHICLE_TOWTRACK;
|
||||||
|
}
|
||||||
|
if (actor->IsA("Ambulance")) {
|
||||||
|
return VEHICLE_AMBULANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VEHICLE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetworkManager::IsInIsleWorld() const
|
||||||
|
{
|
||||||
|
return m_inIsleWorld;
|
||||||
|
}
|
||||||
710
extensions/src/multiplayer/remoteplayer.cpp
Normal file
710
extensions/src/multiplayer/remoteplayer.cpp
Normal file
@ -0,0 +1,710 @@
|
|||||||
|
#include "extensions/multiplayer/remoteplayer.h"
|
||||||
|
|
||||||
|
#include "3dmanager/lego3dmanager.h"
|
||||||
|
#include "anim/legoanim.h"
|
||||||
|
#include "legoactor.h"
|
||||||
|
#include "legoanimpresenter.h"
|
||||||
|
#include "legocharactermanager.h"
|
||||||
|
#include "legovideomanager.h"
|
||||||
|
#include "legoworld.h"
|
||||||
|
#include "misc.h"
|
||||||
|
#include "misc/legotree.h"
|
||||||
|
#include "roi/legoroi.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_log.h>
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
|
#include <SDL3/SDL_timer.h>
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace Multiplayer;
|
||||||
|
|
||||||
|
// Vehicle ROI LOD names, indexed by VehicleType enum
|
||||||
|
// Large vehicles: character hidden, show vehicle ROI only
|
||||||
|
// Small vehicles: character visible with ride animation
|
||||||
|
static const char* g_vehicleROINames[VEHICLE_COUNT] = {
|
||||||
|
"copter", // VEHICLE_HELICOPTER (large)
|
||||||
|
"jsuser", // VEHICLE_JETSKI (large)
|
||||||
|
"dunebugy", // VEHICLE_DUNEBUGGY (large)
|
||||||
|
"bike", // VEHICLE_BIKE (small)
|
||||||
|
"board", // VEHICLE_SKATEBOARD (small)
|
||||||
|
"moto", // VEHICLE_MOTOCYCLE (small)
|
||||||
|
"towtk", // VEHICLE_TOWTRACK (large)
|
||||||
|
"ambul" // VEHICLE_AMBULANCE (large)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ride animation presenter names for small vehicles (NULL for large)
|
||||||
|
static const char* g_rideAnimNames[VEHICLE_COUNT] = {
|
||||||
|
NULL, // VEHICLE_HELICOPTER
|
||||||
|
NULL, // VEHICLE_JETSKI
|
||||||
|
NULL, // VEHICLE_DUNEBUGGY
|
||||||
|
"CNs001Bd", // VEHICLE_BIKE
|
||||||
|
"CNs001sk", // VEHICLE_SKATEBOARD
|
||||||
|
"CNs011Ni", // VEHICLE_MOTOCYCLE
|
||||||
|
NULL, // VEHICLE_TOWTRACK
|
||||||
|
NULL // VEHICLE_AMBULANCE
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vehicle variant ROI names used in ride animations (NULL for large)
|
||||||
|
static const char* g_rideVehicleROINames[VEHICLE_COUNT] = {
|
||||||
|
NULL, // VEHICLE_HELICOPTER
|
||||||
|
NULL, // VEHICLE_JETSKI
|
||||||
|
NULL, // VEHICLE_DUNEBUGGY
|
||||||
|
"bikebd", // VEHICLE_BIKE
|
||||||
|
"board", // VEHICLE_SKATEBOARD
|
||||||
|
"motoni", // VEHICLE_MOTOCYCLE
|
||||||
|
NULL, // VEHICLE_TOWTRACK
|
||||||
|
NULL // VEHICLE_AMBULANCE
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool IsLargeVehicle(int8_t p_vehicleType)
|
||||||
|
{
|
||||||
|
return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId)
|
||||||
|
: m_peerId(p_peerId), m_actorId(p_actorId), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f),
|
||||||
|
m_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1), m_lastUpdateTime(SDL_GetTicks()),
|
||||||
|
m_hasReceivedUpdate(false), m_walkAnim(nullptr), m_walkRoiMap(nullptr), m_walkRoiMapSize(0),
|
||||||
|
m_animTime(0.0f), m_idleTime(0.0f), m_wasMoving(false), m_idleAnim(nullptr), m_idleRoiMap(nullptr),
|
||||||
|
m_idleRoiMapSize(0), m_idleAnimTime(0.0f), m_rideAnim(nullptr), m_rideRoiMap(nullptr),
|
||||||
|
m_rideRoiMapSize(0), m_rideVehicleROI(nullptr),
|
||||||
|
m_vehicleROI(nullptr), m_currentVehicleType(VEHICLE_NONE)
|
||||||
|
{
|
||||||
|
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", LegoActor::GetActorName(p_actorId), p_peerId);
|
||||||
|
|
||||||
|
SDL_memset(m_targetPosition, 0, sizeof(m_targetPosition));
|
||||||
|
m_targetDirection[0] = 0.0f;
|
||||||
|
m_targetDirection[1] = 0.0f;
|
||||||
|
m_targetDirection[2] = 1.0f;
|
||||||
|
m_targetUp[0] = 0.0f;
|
||||||
|
m_targetUp[1] = 1.0f;
|
||||||
|
m_targetUp[2] = 0.0f;
|
||||||
|
|
||||||
|
SDL_memcpy(m_currentPosition, m_targetPosition, sizeof(m_targetPosition));
|
||||||
|
SDL_memcpy(m_currentDirection, m_targetDirection, sizeof(m_targetDirection));
|
||||||
|
SDL_memcpy(m_currentUp, m_targetUp, sizeof(m_targetUp));
|
||||||
|
}
|
||||||
|
|
||||||
|
RemotePlayer::~RemotePlayer()
|
||||||
|
{
|
||||||
|
Despawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::Spawn(LegoWorld* p_isleWorld)
|
||||||
|
{
|
||||||
|
if (m_spawned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoCharacterManager* charMgr = CharacterManager();
|
||||||
|
if (!charMgr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* actorName = LegoActor::GetActorName(m_actorId);
|
||||||
|
if (!actorName) {
|
||||||
|
SDL_Log("Multiplayer: failed to get actor name for id %d", m_actorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a full multi-part character clone with body parts
|
||||||
|
m_roi = charMgr->CreateCharacterClone(m_uniqueName, actorName);
|
||||||
|
|
||||||
|
if (!m_roi) {
|
||||||
|
SDL_Log("Multiplayer: failed to create character clone for %s", m_uniqueName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ROI to the 3D scene and notify the 3D manager
|
||||||
|
VideoManager()->Get3DManager()->Add(*m_roi);
|
||||||
|
VideoManager()->Get3DManager()->Moved(*m_roi);
|
||||||
|
|
||||||
|
// Start hidden until we get a STATE update confirming worldId
|
||||||
|
m_roi->SetVisibility(FALSE);
|
||||||
|
m_spawned = true;
|
||||||
|
m_visible = false;
|
||||||
|
|
||||||
|
// Build walk animation ROI map
|
||||||
|
BuildWalkROIMap(p_isleWorld);
|
||||||
|
|
||||||
|
// Build idle animation ROI map (CNs008xx - breathing/swaying)
|
||||||
|
MxCore* idlePresenter = p_isleWorld->Find("LegoAnimPresenter", "CNs008xx");
|
||||||
|
if (idlePresenter) {
|
||||||
|
m_idleAnim = static_cast<LegoAnimPresenter*>(idlePresenter)->GetAnimation();
|
||||||
|
if (m_idleAnim) {
|
||||||
|
BuildROIMap(m_idleAnim, m_roi, nullptr, m_idleRoiMap, m_idleRoiMapSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: spawned remote player %s (roi=%p, walkRoiMap=%u, idleRoiMap=%u)",
|
||||||
|
m_uniqueName,
|
||||||
|
(void*) m_roi,
|
||||||
|
m_walkRoiMapSize,
|
||||||
|
m_idleRoiMapSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::Despawn()
|
||||||
|
{
|
||||||
|
if (!m_spawned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up vehicle state first
|
||||||
|
ExitVehicle();
|
||||||
|
|
||||||
|
if (m_roi) {
|
||||||
|
VideoManager()->Get3DManager()->Remove(*m_roi);
|
||||||
|
CharacterManager()->ReleaseActor(m_uniqueName);
|
||||||
|
m_roi = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_walkRoiMap) {
|
||||||
|
delete[] m_walkRoiMap;
|
||||||
|
m_walkRoiMap = nullptr;
|
||||||
|
m_walkRoiMapSize = 0;
|
||||||
|
}
|
||||||
|
if (m_idleRoiMap) {
|
||||||
|
delete[] m_idleRoiMap;
|
||||||
|
m_idleRoiMap = nullptr;
|
||||||
|
m_idleRoiMapSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_walkAnim = nullptr;
|
||||||
|
m_idleAnim = nullptr;
|
||||||
|
m_spawned = false;
|
||||||
|
m_visible = false;
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: despawned remote player %s", m_uniqueName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
|
||||||
|
{
|
||||||
|
// Compute speed from position delta (GetWorldSpeed clamps backward movement to 0)
|
||||||
|
float dx = p_msg.position[0] - m_targetPosition[0];
|
||||||
|
float dy = p_msg.position[1] - m_targetPosition[1];
|
||||||
|
float dz = p_msg.position[2] - m_targetPosition[2];
|
||||||
|
float posDelta = SDL_sqrtf(dx * dx + dy * dy + dz * dz);
|
||||||
|
|
||||||
|
SDL_memcpy(m_targetPosition, p_msg.position, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(m_targetDirection, p_msg.direction, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(m_targetUp, p_msg.up, sizeof(float) * 3);
|
||||||
|
m_targetSpeed = posDelta > 0.01f ? posDelta : 0.0f;
|
||||||
|
m_targetVehicleType = p_msg.vehicleType;
|
||||||
|
m_targetWorldId = p_msg.worldId;
|
||||||
|
m_lastUpdateTime = SDL_GetTicks();
|
||||||
|
|
||||||
|
if (!m_hasReceivedUpdate) {
|
||||||
|
// Snap to position on first update (don't interpolate from origin)
|
||||||
|
SDL_memcpy(m_currentPosition, m_targetPosition, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(m_currentDirection, m_targetDirection, sizeof(float) * 3);
|
||||||
|
SDL_memcpy(m_currentUp, m_targetUp, sizeof(float) * 3);
|
||||||
|
m_hasReceivedUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::Tick(float p_deltaTime)
|
||||||
|
{
|
||||||
|
if (!m_spawned || !m_visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log first tick to confirm the player is being updated
|
||||||
|
static uint32_t lastLoggedPeer = 0;
|
||||||
|
if (lastLoggedPeer != m_peerId) {
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: first tick for %s pos=(%.1f,%.1f,%.1f) hasUpdate=%d",
|
||||||
|
m_uniqueName,
|
||||||
|
m_currentPosition[0],
|
||||||
|
m_currentPosition[1],
|
||||||
|
m_currentPosition[2],
|
||||||
|
m_hasReceivedUpdate
|
||||||
|
);
|
||||||
|
lastLoggedPeer = m_peerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateVehicleState();
|
||||||
|
UpdateTransform(p_deltaTime);
|
||||||
|
UpdateAnimation(p_deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::ReAddToScene()
|
||||||
|
{
|
||||||
|
if (m_spawned && m_roi) {
|
||||||
|
VideoManager()->Get3DManager()->Add(*m_roi);
|
||||||
|
}
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
VideoManager()->Get3DManager()->Add(*m_vehicleROI);
|
||||||
|
}
|
||||||
|
if (m_rideVehicleROI) {
|
||||||
|
VideoManager()->Get3DManager()->Add(*m_rideVehicleROI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::SetVisible(bool p_visible)
|
||||||
|
{
|
||||||
|
if (!m_spawned || !m_roi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_visible = p_visible;
|
||||||
|
|
||||||
|
if (p_visible) {
|
||||||
|
// Visibility depends on vehicle state
|
||||||
|
if (m_currentVehicleType != VEHICLE_NONE && IsLargeVehicle(m_currentVehicleType)) {
|
||||||
|
m_roi->SetVisibility(FALSE);
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
m_vehicleROI->SetVisibility(TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
m_roi->SetVisibility(TRUE);
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
m_vehicleROI->SetVisibility(FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ride vehicle ROI visibility is managed by the animation (ApplyAnimationTransformation)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
m_roi->SetVisibility(FALSE);
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
m_vehicleROI->SetVisibility(FALSE);
|
||||||
|
}
|
||||||
|
if (m_rideVehicleROI) {
|
||||||
|
m_rideVehicleROI->SetVisibility(FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::BuildWalkROIMap(LegoWorld* p_isleWorld)
|
||||||
|
{
|
||||||
|
if (!p_isleWorld) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the generic slow walk animation presenter "CNs001xx"
|
||||||
|
MxCore* presenter = p_isleWorld->Find("LegoAnimPresenter", "CNs001xx");
|
||||||
|
if (!presenter) {
|
||||||
|
SDL_Log("Multiplayer: walk animation presenter CNs001xx not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoAnimPresenter* animPresenter = static_cast<LegoAnimPresenter*>(presenter);
|
||||||
|
m_walkAnim = animPresenter->GetAnimation();
|
||||||
|
|
||||||
|
if (!m_walkAnim) {
|
||||||
|
SDL_Log("Multiplayer: walk animation data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildROIMap(m_walkAnim, m_roi, nullptr, m_walkRoiMap, m_walkRoiMapSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the animation tree, assign ROI indices, and collect matched ROIs.
|
||||||
|
// This mirrors the game's UpdateStructMapAndROIIndex approach: ROI indices
|
||||||
|
// are assigned at runtime via SetROIIndex() and are NOT pre-stored in animation
|
||||||
|
// data (m_roiIndex starts at 0 for all nodes).
|
||||||
|
static void AssignROIIndices(
|
||||||
|
LegoTreeNode* p_node,
|
||||||
|
LegoROI* p_parentROI,
|
||||||
|
LegoROI* p_rootROI,
|
||||||
|
LegoROI* p_extraROI,
|
||||||
|
MxU32& p_nextIndex,
|
||||||
|
std::vector<LegoROI*>& p_entries,
|
||||||
|
int p_depth = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
LegoROI* roi = p_parentROI;
|
||||||
|
LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData();
|
||||||
|
const char* name = data ? data->GetName() : nullptr;
|
||||||
|
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: [ROIMap] depth=%d name='%s' parentROI=%p rootROI=%p children=%d",
|
||||||
|
p_depth,
|
||||||
|
name ? name : "(null)",
|
||||||
|
(void*) p_parentROI,
|
||||||
|
(void*) p_rootROI,
|
||||||
|
p_node->GetNumChildren()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name != nullptr && *name != '-') {
|
||||||
|
LegoROI* matchedROI = nullptr;
|
||||||
|
|
||||||
|
if (*name == '*' || p_parentROI == nullptr) {
|
||||||
|
// Root-level node: either "*pepper" style or "actor_01" style variable reference.
|
||||||
|
// Game resolves via GetVariableOrIdentity + FindROI; we map directly to our clone.
|
||||||
|
roi = p_rootROI;
|
||||||
|
matchedROI = p_rootROI;
|
||||||
|
SDL_Log("Multiplayer: [ROIMap] matched root node '%s' to rootROI", name);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Body part → search in parent's ROI hierarchy
|
||||||
|
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
|
||||||
|
SDL_Log(
|
||||||
|
"Multiplayer: [ROIMap] FindChildROI('%s', parentROI=%p) = %p",
|
||||||
|
name,
|
||||||
|
(void*) p_parentROI,
|
||||||
|
(void*) matchedROI
|
||||||
|
);
|
||||||
|
if (matchedROI == nullptr && p_extraROI != nullptr) {
|
||||||
|
// Try extra ROI hierarchy (vehicle variant for ride animations)
|
||||||
|
matchedROI = p_extraROI->FindChildROI(name, p_extraROI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedROI != nullptr) {
|
||||||
|
data->SetROIIndex(p_nextIndex);
|
||||||
|
p_entries.push_back(matchedROI);
|
||||||
|
p_nextIndex++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
data->SetROIIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
|
||||||
|
AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries, p_depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::BuildROIMap(
|
||||||
|
LegoAnim* p_anim,
|
||||||
|
LegoROI* p_rootROI,
|
||||||
|
LegoROI* p_extraROI,
|
||||||
|
LegoROI**& p_roiMap,
|
||||||
|
MxU32& p_roiMapSize
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!p_anim || !p_rootROI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoTreeNode* root = p_anim->GetRoot();
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse tree, assigning ROI indices and collecting matched ROIs
|
||||||
|
MxU32 nextIndex = 1;
|
||||||
|
std::vector<LegoROI*> entries;
|
||||||
|
AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries);
|
||||||
|
|
||||||
|
if (entries.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the ROI map array (1-indexed; index 0 reserved as NULL)
|
||||||
|
p_roiMapSize = entries.size() + 1;
|
||||||
|
p_roiMap = new LegoROI*[p_roiMapSize];
|
||||||
|
p_roiMap[0] = nullptr;
|
||||||
|
for (MxU32 i = 0; i < entries.size(); i++) {
|
||||||
|
p_roiMap[i + 1] = entries[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::UpdateTransform(float p_deltaTime)
|
||||||
|
{
|
||||||
|
// Interpolate position toward target
|
||||||
|
float lerpFactor = 0.2f;
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
m_currentPosition[i] += (m_targetPosition[i] - m_currentPosition[i]) * lerpFactor;
|
||||||
|
m_currentDirection[i] += (m_targetDirection[i] - m_currentDirection[i]) * lerpFactor;
|
||||||
|
m_currentUp[i] += (m_targetUp[i] - m_currentUp[i]) * lerpFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build transform using CalcLocalTransform convention from realtime.cpp:
|
||||||
|
// z = normalize(dir), y = normalize(up), x = y×z, y = z×x
|
||||||
|
// Non-player character clones need negated direction (see legopathactor.cpp:152)
|
||||||
|
float z[3], y[3], x[3];
|
||||||
|
z[0] = -m_currentDirection[0];
|
||||||
|
z[1] = -m_currentDirection[1];
|
||||||
|
z[2] = -m_currentDirection[2];
|
||||||
|
|
||||||
|
float zLen = SDL_sqrtf(z[0] * z[0] + z[1] * z[1] + z[2] * z[2]);
|
||||||
|
if (zLen > 0.001f) {
|
||||||
|
z[0] /= zLen;
|
||||||
|
z[1] /= zLen;
|
||||||
|
z[2] /= zLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
float yLen = SDL_sqrtf(
|
||||||
|
m_currentUp[0] * m_currentUp[0] + m_currentUp[1] * m_currentUp[1] + m_currentUp[2] * m_currentUp[2]
|
||||||
|
);
|
||||||
|
y[0] = yLen > 0.001f ? m_currentUp[0] / yLen : 0.0f;
|
||||||
|
y[1] = yLen > 0.001f ? m_currentUp[1] / yLen : 1.0f;
|
||||||
|
y[2] = yLen > 0.001f ? m_currentUp[2] / yLen : 0.0f;
|
||||||
|
|
||||||
|
// x = y × z
|
||||||
|
x[0] = y[1] * z[2] - y[2] * z[1];
|
||||||
|
x[1] = y[2] * z[0] - y[0] * z[2];
|
||||||
|
x[2] = y[0] * z[1] - y[1] * z[0];
|
||||||
|
float xLen = SDL_sqrtf(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]);
|
||||||
|
if (xLen > 0.001f) {
|
||||||
|
x[0] /= xLen;
|
||||||
|
x[1] /= xLen;
|
||||||
|
x[2] /= xLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// y = z × x (re-orthogonalize)
|
||||||
|
y[0] = z[1] * x[2] - z[2] * x[1];
|
||||||
|
y[1] = z[2] * x[0] - z[0] * x[2];
|
||||||
|
y[2] = z[0] * x[1] - z[1] * x[0];
|
||||||
|
yLen = SDL_sqrtf(y[0] * y[0] + y[1] * y[1] + y[2] * y[2]);
|
||||||
|
if (yLen > 0.001f) {
|
||||||
|
y[0] /= yLen;
|
||||||
|
y[1] /= yLen;
|
||||||
|
y[2] /= yLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 4x4 transform matrix [right, up, direction, position] as rows
|
||||||
|
MxMatrix mat;
|
||||||
|
mat[0][0] = x[0];
|
||||||
|
mat[0][1] = x[1];
|
||||||
|
mat[0][2] = x[2];
|
||||||
|
mat[0][3] = 0.0f;
|
||||||
|
mat[1][0] = y[0];
|
||||||
|
mat[1][1] = y[1];
|
||||||
|
mat[1][2] = y[2];
|
||||||
|
mat[1][3] = 0.0f;
|
||||||
|
mat[2][0] = z[0];
|
||||||
|
mat[2][1] = z[1];
|
||||||
|
mat[2][2] = z[2];
|
||||||
|
mat[2][3] = 0.0f;
|
||||||
|
mat[3][0] = m_currentPosition[0];
|
||||||
|
mat[3][1] = m_currentPosition[1];
|
||||||
|
mat[3][2] = m_currentPosition[2];
|
||||||
|
mat[3][3] = 1.0f;
|
||||||
|
|
||||||
|
m_roi->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
|
||||||
|
VideoManager()->Get3DManager()->Moved(*m_roi);
|
||||||
|
|
||||||
|
// Also update vehicle ROI transform if in large vehicle
|
||||||
|
if (m_vehicleROI && m_currentVehicleType != VEHICLE_NONE && IsLargeVehicle(m_currentVehicleType)) {
|
||||||
|
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
|
||||||
|
VideoManager()->Get3DManager()->Moved(*m_vehicleROI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::UpdateAnimation(float p_deltaTime)
|
||||||
|
{
|
||||||
|
// Determine which animation and ROI map to use
|
||||||
|
LegoAnim* anim = nullptr;
|
||||||
|
|
||||||
|
if (m_currentVehicleType != VEHICLE_NONE && IsLargeVehicle(m_currentVehicleType)) {
|
||||||
|
// Large vehicle: no animation, character is hidden
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoROI** roiMap = nullptr;
|
||||||
|
|
||||||
|
if (m_currentVehicleType != VEHICLE_NONE && m_rideAnim && m_rideRoiMap) {
|
||||||
|
// Small vehicle: use ride animation
|
||||||
|
anim = m_rideAnim;
|
||||||
|
roiMap = m_rideRoiMap;
|
||||||
|
}
|
||||||
|
else if (m_walkAnim && m_walkRoiMap) {
|
||||||
|
// On foot: use walk animation
|
||||||
|
anim = m_walkAnim;
|
||||||
|
roiMap = m_walkRoiMap;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all body parts are visible before animation (matches game's AnimateWithTransform)
|
||||||
|
MxU32 roiMapSize = (roiMap == m_walkRoiMap) ? m_walkRoiMapSize : m_rideRoiMapSize;
|
||||||
|
MxU32 idleMapSize = m_idleRoiMapSize;
|
||||||
|
for (MxU32 i = 1; i < roiMapSize; i++) {
|
||||||
|
if (roiMap[i] != nullptr) {
|
||||||
|
roiMap[i]->SetVisibility(TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also ensure idle ROI map parts are visible (may include different body parts)
|
||||||
|
for (MxU32 i = 1; i < idleMapSize; i++) {
|
||||||
|
if (m_idleRoiMap[i] != nullptr) {
|
||||||
|
m_idleRoiMap[i]->SetVisibility(TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool inVehicle = (m_currentVehicleType != VEHICLE_NONE);
|
||||||
|
|
||||||
|
if (inVehicle || m_targetSpeed > 0.01f) {
|
||||||
|
// Moving or in vehicle: advance animation time in LegoTime units (ms-scale)
|
||||||
|
// Game uses: m_actorTime += deltaTime_ms * worldSpeed (see legopathactor.cpp:359)
|
||||||
|
// When on a vehicle but standing still, freeze at frame 0
|
||||||
|
if (m_targetSpeed > 0.01f) {
|
||||||
|
m_animTime += p_deltaTime * 2000.0f;
|
||||||
|
}
|
||||||
|
float duration = (float) anim->GetDuration();
|
||||||
|
if (duration > 0.0f) {
|
||||||
|
float timeInCycle = m_animTime - duration * floorf(m_animTime / duration);
|
||||||
|
|
||||||
|
MxMatrix transform(m_roi->GetLocal2World());
|
||||||
|
LegoTreeNode* root = anim->GetRoot();
|
||||||
|
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
||||||
|
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, roiMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_wasMoving = true;
|
||||||
|
m_idleTime = 0.0f;
|
||||||
|
m_idleAnimTime = 0.0f;
|
||||||
|
}
|
||||||
|
else if (m_idleAnim && m_idleRoiMap) {
|
||||||
|
// Standing still on foot: use the dedicated idle animation (CNs008xx)
|
||||||
|
if (m_wasMoving) {
|
||||||
|
m_wasMoving = false;
|
||||||
|
m_idleTime = 0.0f;
|
||||||
|
m_idleAnimTime = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_idleTime += p_deltaTime;
|
||||||
|
|
||||||
|
// Play idle animation: frame 0 for first 2.5s (standing pose),
|
||||||
|
// then continuously loop (breathing/swaying effect)
|
||||||
|
if (m_idleTime >= 2.5f) {
|
||||||
|
m_idleAnimTime += p_deltaTime * 1000.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float duration = (float) m_idleAnim->GetDuration();
|
||||||
|
if (duration > 0.0f) {
|
||||||
|
float timeInCycle = m_idleAnimTime - duration * floorf(m_idleAnimTime / duration);
|
||||||
|
|
||||||
|
MxMatrix transform(m_roi->GetLocal2World());
|
||||||
|
LegoTreeNode* root = m_idleAnim->GetRoot();
|
||||||
|
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
||||||
|
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, m_idleRoiMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::UpdateVehicleState()
|
||||||
|
{
|
||||||
|
if (m_targetVehicleType != m_currentVehicleType) {
|
||||||
|
if (m_targetVehicleType == VEHICLE_NONE) {
|
||||||
|
// Exiting vehicle
|
||||||
|
ExitVehicle();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Entering vehicle (exit old one first if needed)
|
||||||
|
if (m_currentVehicleType != VEHICLE_NONE) {
|
||||||
|
ExitVehicle();
|
||||||
|
}
|
||||||
|
EnterVehicle(m_targetVehicleType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
|
||||||
|
{
|
||||||
|
if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_currentVehicleType = p_vehicleType;
|
||||||
|
m_animTime = 0.0f;
|
||||||
|
|
||||||
|
if (IsLargeVehicle(p_vehicleType)) {
|
||||||
|
// Large vehicle: hide character, show vehicle ROI
|
||||||
|
m_roi->SetVisibility(FALSE);
|
||||||
|
|
||||||
|
// Create vehicle ROI clone
|
||||||
|
char vehicleName[48];
|
||||||
|
SDL_snprintf(vehicleName, sizeof(vehicleName), "%s_mp_%u", g_vehicleROINames[p_vehicleType], m_peerId);
|
||||||
|
|
||||||
|
m_vehicleROI = CharacterManager()->CreateAutoROI(vehicleName, g_vehicleROINames[p_vehicleType], FALSE);
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
// CreateAutoROI already adds to 3D scene via Get3DManager()->Add()
|
||||||
|
// Position at current transform
|
||||||
|
MxMatrix mat(m_roi->GetLocal2World());
|
||||||
|
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
|
||||||
|
m_vehicleROI->SetVisibility(m_visible ? TRUE : FALSE);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
SDL_Log("Multiplayer: failed to create vehicle ROI for type %d", p_vehicleType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Small vehicle: find ride animation and build ride ROI map
|
||||||
|
const char* rideAnimName = g_rideAnimNames[p_vehicleType];
|
||||||
|
const char* vehicleVariantName = g_rideVehicleROINames[p_vehicleType];
|
||||||
|
|
||||||
|
if (!rideAnimName || !vehicleVariantName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the ride animation presenter
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (!world) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MxCore* presenter = world->Find("LegoAnimPresenter", rideAnimName);
|
||||||
|
if (!presenter) {
|
||||||
|
SDL_Log("Multiplayer: ride animation presenter %s not found", rideAnimName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoAnimPresenter* animPresenter = static_cast<LegoAnimPresenter*>(presenter);
|
||||||
|
m_rideAnim = animPresenter->GetAnimation();
|
||||||
|
if (!m_rideAnim) {
|
||||||
|
SDL_Log("Multiplayer: ride animation data is null for %s", rideAnimName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create vehicle variant ROI for the ride animation
|
||||||
|
char variantName[48];
|
||||||
|
SDL_snprintf(variantName, sizeof(variantName), "%s_mp_%u", vehicleVariantName, m_peerId);
|
||||||
|
m_rideVehicleROI = CharacterManager()->CreateAutoROI(variantName, vehicleVariantName, FALSE);
|
||||||
|
// CreateAutoROI already adds to 3D scene via Get3DManager()->Add()
|
||||||
|
|
||||||
|
// Rename to base name so FindChildROI in AssignROIIndices can match animation tree nodes.
|
||||||
|
// CreateAutoROI sets name to unique variantName (e.g. "board_mp_2") but animation nodes
|
||||||
|
// expect the base name (e.g. "board"). ReleaseAutoROI uses pointer comparison, not name.
|
||||||
|
if (m_rideVehicleROI) {
|
||||||
|
m_rideVehicleROI->SetName(vehicleVariantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the ride ROI map with both character body parts and vehicle variant
|
||||||
|
BuildROIMap(m_rideAnim, m_roi, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemotePlayer::ExitVehicle()
|
||||||
|
{
|
||||||
|
if (m_currentVehicleType == VEHICLE_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up large vehicle ROI
|
||||||
|
if (m_vehicleROI) {
|
||||||
|
VideoManager()->Get3DManager()->Remove(*m_vehicleROI);
|
||||||
|
CharacterManager()->ReleaseAutoROI(m_vehicleROI);
|
||||||
|
m_vehicleROI = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up ride animation state
|
||||||
|
if (m_rideRoiMap) {
|
||||||
|
delete[] m_rideRoiMap;
|
||||||
|
m_rideRoiMap = nullptr;
|
||||||
|
m_rideRoiMapSize = 0;
|
||||||
|
}
|
||||||
|
if (m_rideVehicleROI) {
|
||||||
|
VideoManager()->Get3DManager()->Remove(*m_rideVehicleROI);
|
||||||
|
CharacterManager()->ReleaseAutoROI(m_rideVehicleROI);
|
||||||
|
m_rideVehicleROI = nullptr;
|
||||||
|
}
|
||||||
|
m_rideAnim = nullptr;
|
||||||
|
|
||||||
|
// Show character again
|
||||||
|
if (m_visible) {
|
||||||
|
m_roi->SetVisibility(TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_currentVehicleType = VEHICLE_NONE;
|
||||||
|
m_animTime = 0.0f;
|
||||||
|
m_wasMoving = false;
|
||||||
|
}
|
||||||
111
extensions/src/multiplayer/server/relay.ts
Normal file
111
extensions/src/multiplayer/server/relay.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
export interface Env {
|
||||||
|
GAME_ROOM: DurableObjectNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
// Route: /room/:roomId
|
||||||
|
if (pathParts.length === 2 && pathParts[0] === "room") {
|
||||||
|
const roomId = pathParts[1];
|
||||||
|
const id = env.GAME_ROOM.idFromName(roomId);
|
||||||
|
const room = env.GAME_ROOM.get(id);
|
||||||
|
return room.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
if (url.pathname === "/" || url.pathname === "/health") {
|
||||||
|
return new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GameRoom implements DurableObject {
|
||||||
|
private connections: Map<string, WebSocket> = new Map();
|
||||||
|
private nextPeerId: number = 1;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private state: DurableObjectState,
|
||||||
|
private env: Env
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
if (request.headers.get("Upgrade") !== "websocket") {
|
||||||
|
return new Response("Expected WebSocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const [client, server] = [pair[0], pair[1]];
|
||||||
|
|
||||||
|
const peerId = this.nextPeerId++;
|
||||||
|
const peerIdStr = String(peerId);
|
||||||
|
|
||||||
|
this.state.acceptWebSocket(server);
|
||||||
|
this.connections.set(peerIdStr, server);
|
||||||
|
|
||||||
|
// Send the peer its assigned ID as the first message
|
||||||
|
const idMsg = new ArrayBuffer(5);
|
||||||
|
const view = new DataView(idMsg);
|
||||||
|
view.setUint8(0, 0xff); // Special "assign ID" message type
|
||||||
|
view.setUint32(1, peerId, true); // little-endian peer ID
|
||||||
|
server.send(idMsg);
|
||||||
|
|
||||||
|
server.addEventListener("message", (event) => {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(event.data);
|
||||||
|
if (data.length < 9) {
|
||||||
|
return; // Too short for header
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the peerId into the message header (bytes 1-4)
|
||||||
|
const stamped = new Uint8Array(data.length);
|
||||||
|
stamped.set(data);
|
||||||
|
new DataView(stamped.buffer).setUint32(1, peerId, true);
|
||||||
|
|
||||||
|
// Broadcast to all other peers in this room
|
||||||
|
for (const [id, ws] of this.connections) {
|
||||||
|
if (id !== peerIdStr) {
|
||||||
|
try {
|
||||||
|
ws.send(stamped.buffer);
|
||||||
|
} catch {
|
||||||
|
this.connections.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener("close", () => {
|
||||||
|
this.connections.delete(peerIdStr);
|
||||||
|
|
||||||
|
// Broadcast LEAVE message to remaining peers
|
||||||
|
const leaveMsg = new ArrayBuffer(9);
|
||||||
|
const leaveView = new DataView(leaveMsg);
|
||||||
|
leaveView.setUint8(0, 2); // MSG_LEAVE
|
||||||
|
leaveView.setUint32(1, peerId, true);
|
||||||
|
leaveView.setUint32(5, 0, true); // sequence 0
|
||||||
|
|
||||||
|
for (const [, ws] of this.connections) {
|
||||||
|
try {
|
||||||
|
ws.send(leaveMsg);
|
||||||
|
} catch {
|
||||||
|
// Ignore send errors on cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener("error", () => {
|
||||||
|
this.connections.delete(peerIdStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
|
}
|
||||||
|
}
|
||||||
199
extensions/src/multiplayer/websockettransport.cpp
Normal file
199
extensions/src/multiplayer/websockettransport.cpp
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/websockettransport.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_log.h>
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
|
#include <emscripten.h>
|
||||||
|
|
||||||
|
namespace Multiplayer
|
||||||
|
{
|
||||||
|
|
||||||
|
WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl)
|
||||||
|
: m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0)
|
||||||
|
{
|
||||||
|
// clang-format off
|
||||||
|
MAIN_THREAD_EM_ASM({
|
||||||
|
if (!Module._mpSockets) {
|
||||||
|
Module._mpSockets = {};
|
||||||
|
Module._mpNextSocketId = 1;
|
||||||
|
Module._mpMessageQueues = {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// clang-format on
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketTransport::~WebSocketTransport()
|
||||||
|
{
|
||||||
|
Disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebSocketTransport::Connect(const char* p_roomId)
|
||||||
|
{
|
||||||
|
if (m_connectedFlag) {
|
||||||
|
Disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = m_relayBaseUrl + "/room/" + p_roomId;
|
||||||
|
|
||||||
|
// Pass the address of m_connectedFlag so JS callbacks can update it
|
||||||
|
// directly via shared WASM heap memory, avoiding proxy calls for IsConnected().
|
||||||
|
// clang-format off
|
||||||
|
m_socketId = MAIN_THREAD_EM_ASM_INT({
|
||||||
|
var url = UTF8ToString($0);
|
||||||
|
var connPtr = $1;
|
||||||
|
var socketId = Module._mpNextSocketId++;
|
||||||
|
Module._mpMessageQueues[socketId] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
var ws = new WebSocket(url);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
Atomics.store(HEAP32, connPtr >> 2, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
var data = new Uint8Array(event.data);
|
||||||
|
Module._mpMessageQueues[socketId].push(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
Atomics.store(HEAP32, connPtr >> 2, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function() {
|
||||||
|
Atomics.store(HEAP32, connPtr >> 2, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
Module._mpSockets[socketId] = ws;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WebSocket connect error:', e);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return socketId;
|
||||||
|
}, url.c_str(), &m_connectedFlag);
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
if (m_socketId > 0) {
|
||||||
|
SDL_Log("Multiplayer: connecting to %s", url.c_str());
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
SDL_Log("Multiplayer: failed to create WebSocket connection to %s", url.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebSocketTransport::Disconnect()
|
||||||
|
{
|
||||||
|
if (m_socketId > 0) {
|
||||||
|
// clang-format off
|
||||||
|
MAIN_THREAD_EM_ASM({
|
||||||
|
var socketId = $0;
|
||||||
|
if (Module._mpSockets[socketId]) {
|
||||||
|
Module._mpSockets[socketId].close();
|
||||||
|
delete Module._mpSockets[socketId];
|
||||||
|
}
|
||||||
|
delete Module._mpMessageQueues[socketId];
|
||||||
|
}, m_socketId);
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
SDL_Log("Multiplayer: disconnected");
|
||||||
|
m_socketId = -1;
|
||||||
|
m_connectedFlag = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WebSocketTransport::IsConnected() const
|
||||||
|
{
|
||||||
|
// Read the shared flag directly from WASM heap memory.
|
||||||
|
// No proxy call needed - the JS callbacks update this via Atomics.store.
|
||||||
|
return m_socketId > 0 && m_connectedFlag != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length)
|
||||||
|
{
|
||||||
|
if (m_socketId <= 0 || !m_connectedFlag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
MAIN_THREAD_EM_ASM({
|
||||||
|
var socketId = $0;
|
||||||
|
var dataPtr = $1;
|
||||||
|
var length = $2;
|
||||||
|
var ws = Module._mpSockets[socketId];
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
var buffer = new Uint8Array(HEAPU8.buffer, dataPtr, length);
|
||||||
|
var copy = new Uint8Array(length);
|
||||||
|
copy.set(buffer);
|
||||||
|
ws.send(copy.buffer);
|
||||||
|
}
|
||||||
|
}, m_socketId, p_data, (int) p_length);
|
||||||
|
// clang-format on
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t WebSocketTransport::Receive(std::function<void(const uint8_t*, size_t)> p_callback)
|
||||||
|
{
|
||||||
|
if (m_socketId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain all queued messages in a single proxy call to avoid starving the main thread event loop.
|
||||||
|
// Each message is concatenated as [4-byte LE length][payload...].
|
||||||
|
// clang-format off
|
||||||
|
int totalBytes = MAIN_THREAD_EM_ASM_INT({
|
||||||
|
var socketId = $0;
|
||||||
|
var destPtr = $1;
|
||||||
|
var maxBytes = $2;
|
||||||
|
var queue = Module._mpMessageQueues[socketId];
|
||||||
|
if (!queue || queue.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var offset = 0;
|
||||||
|
var view = new DataView(HEAPU8.buffer);
|
||||||
|
while (queue.length > 0) {
|
||||||
|
var msg = queue[0];
|
||||||
|
var needed = 4 + msg.length;
|
||||||
|
if (offset + needed > maxBytes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
view.setUint32(destPtr + offset, msg.length, true);
|
||||||
|
offset += 4;
|
||||||
|
HEAPU8.set(msg, destPtr + offset);
|
||||||
|
offset += msg.length;
|
||||||
|
queue.shift();
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}, m_socketId, m_recvBuf, (int) sizeof(m_recvBuf));
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
if (totalBytes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t processed = 0;
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
while (offset + 4 <= totalBytes) {
|
||||||
|
uint32_t msgLen;
|
||||||
|
SDL_memcpy(&msgLen, m_recvBuf + offset, sizeof(uint32_t));
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
if (msgLen == 0 || offset + (int) msgLen > totalBytes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
p_callback(m_recvBuf + offset, (size_t) msgLen);
|
||||||
|
offset += msgLen;
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Multiplayer
|
||||||
|
|
||||||
|
#endif // __EMSCRIPTEN__
|
||||||
Loading…
Reference in New Issue
Block a user