diff --git a/.gitignore b/.gitignore index a4bd8a41..ae4d7a11 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,7 @@ VENV/ env.bak/ venv.bak/ local.properties -/build/ -/build_debug/ +/build*/ /legobin/ *.swp LEGO1PROGRESS.* diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e8eee53..2ffd501b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -531,6 +531,10 @@ if (ISLE_EXTENSIONS) extensions/src/extensions.cpp extensions/src/siloader.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() diff --git a/ISLE/emscripten/config.cpp b/ISLE/emscripten/config.cpp index 58a42704..023b2e5f 100644 --- a/ISLE/emscripten/config.cpp +++ b/ISLE/emscripten/config.cpp @@ -17,6 +17,11 @@ void Emscripten_SetupDefaultConfigOverrides(dictionary* p_dictionary) iniparser_set(p_dictionary, "isle:Full Screen", "false"); 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_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true)); Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true)); diff --git a/LEGO1/lego/legoomni/include/legocharactermanager.h b/LEGO1/lego/legoomni/include/legocharactermanager.h index 4443ca8a..04aba71b 100644 --- a/LEGO1/lego/legoomni/include/legocharactermanager.h +++ b/LEGO1/lego/legoomni/include/legocharactermanager.h @@ -92,6 +92,7 @@ class LegoCharacterManager { MxU32 GetSoundId(LegoROI* p_roi, MxBool p_basedOnMood); MxU8 GetMood(LegoROI* p_roi); 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); LegoROI* FUN_10085a80(const char* p_name, const char* p_lodName, MxBool p_createEntity); diff --git a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp index 180b8eec..e4bed0f1 100644 --- a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp +++ b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp @@ -1110,3 +1110,146 @@ void CustomizeAnimFileVariable::SetValue(const char* 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; +} diff --git a/LEGO1/lego/legoomni/src/entity/legoworld.cpp b/LEGO1/lego/legoomni/src/entity/legoworld.cpp index 976c9417..dc741d08 100644 --- a/LEGO1/lego/legoomni/src/entity/legoworld.cpp +++ b/LEGO1/lego/legoomni/src/entity/legoworld.cpp @@ -1,6 +1,7 @@ #include "legoworld.h" #include "anim/legoanim.h" +#include "extensions/multiplayer.h" #include "extensions/siloader.h" #include "legoanimationmanager.h" #include "legoanimpresenter.h" @@ -753,6 +754,7 @@ void LegoWorld::Enable(MxBool p_enable) #ifndef BETA10 SetIsWorldActive(TRUE); #endif + Extensions::Extension::Call(Extensions::HandleWorldEnable, this, TRUE); } else if (!p_enable && m_disabledObjects.size() == 0) { MxPresenter* presenter; @@ -815,6 +817,7 @@ void LegoWorld::Enable(MxBool p_enable) } GetViewManager()->RemoveAll(NULL); + Extensions::Extension::Call(Extensions::HandleWorldEnable, this, FALSE); } } diff --git a/docs/multiplayer-mvp-assessment.md b/docs/multiplayer-mvp-assessment.md new file mode 100644 index 00000000..f26e24a0 --- /dev/null +++ b/docs/multiplayer-mvp-assessment.md @@ -0,0 +1,1020 @@ +# Multiplayer MVP Feasibility Assessment for isle-portable + +## Context + +LEGO Island is a single-player game from 1997. The isle-portable project is a cross-platform decompilation/port running on Emscripten (WebAssembly), Windows, macOS, Linux, 3DS, Switch, Vita, iOS, Android, and Xbox. The goal is to assess feasibility and plan an MVP where players can see each other's characters walking around in the ISLE overworld. The approach prioritizes **maximum reuse of existing game code** and a **WebSocket relay via Cloudflare Workers**. + +The original game shipped with DirectPlay headers (`3rdparty/dx5/inc/dplay.h`) but never implemented multiplayer. There is **zero existing networking code**. + +--- + +## Feasibility Assessment: STRONG YES + +The codebase is well-suited for this feature because: + +1. **Character creation is already abstracted**: `LegoCharacterManager` builds fully-textured, multi-part character models (head, body, arms, legs with configurable textures/colors) on demand with ref counting. + +2. **Transform system is clean**: `OrientableROI::SetLocal2WorldWithWorldDataUpdate(matrix)` positions any ROI in the world using a 4x4 matrix. No need to interact with path-following or collision systems. + +3. **Animation is static and reusable**: `LegoROI::ApplyAnimationTransformation()` is a static function that animates any set of ROIs given an animation tree and time value - no `LegoAnimActor` or entity required. + +4. **The game loop is hookable**: `MxTickleManager` allows any `MxCore` subclass to register for periodic callbacks at arbitrary intervals. + +5. **Extensions pattern exists**: The `extensions/` system provides a clean model for conditionally-compiled features with runtime enable/disable. + +6. **Emscripten support is mature**: pthreads, WebGL2, WASMFS, and JS interop patterns (`EM_JS`, `Emscripten_SendEvent`) are already established. + +7. **The data to sync is tiny**: ~48 bytes per player at 10-15Hz = ~3 KB/s for 4 players. + +**Key constraint**: Duplicate characters require a new `CreateCharacterClone()` function (see below), but it's a straightforward ~100 line addition. Vehicle cloning uses the existing `CreateAutoROI()` function with no core code changes needed. + +--- + +## Scope: ISLE World Only + +Only the main ISLE world (the island overworld) is relevant for multiplayer. All other areas (Act 1/2/3, Infocenter, Police/Fire/Hospital stations, races, etc.) are instanced single-player experiences. + +This simplifies the design significantly: +- Remote players are only visible when **both** players are in the ISLE world +- Remote players are hidden whenever the local player enters any non-ISLE area +- Only one set of animation presenters to find (the ISLE world's) +- No cross-world state tracking needed + +--- + +## The Duplicate Character Problem + +**Question**: Can two players be the same character (e.g., both Pepper)? + +**Answer**: Not with the existing API. `LegoCharacterManager::GetActorROI()` uses a name-keyed map (`m_characters`) and returns the **same shared ROI** for duplicate calls. One ROI cannot be in two positions simultaneously. + +**Root cause** (`legocharactermanager.cpp:250-296`): +```cpp +LegoROI* LegoCharacterManager::GetActorROI(const char* p_name, ...) { + it = m_characters->find(const_cast(p_name)); + if (!(it == m_characters->end())) { + character = (*it).second; + character->AddRef(); // just bumps refcount, same ROI + } + return character->m_roi; // shared instance +} +``` + +Additionally, `CreateActorROI()` writes `info->m_roi = roi` into the global `g_actorInfo[66]` table, which has exactly one slot per character type. + +**Solution: `CreateCharacterClone()` function** + +Add a new method to `LegoCharacterManager` that decouples appearance lookup from ROI identity: + +```cpp +// New function - reuses CreateActorROI's construction logic +// but with a unique name and no side effects on g_actorInfo +LegoROI* LegoCharacterManager::CreateCharacterClone( + const char* p_uniqueName, // e.g., "pepper_mp_42" + const char* p_characterType // e.g., "pepper" (for appearance lookup) +) { + LegoActorInfo* info = GetActorInfo(p_characterType); + if (!info) return NULL; + + // Same body construction loop as CreateActorROI(): + // - Create root ROI with p_uniqueName + // - Build child ROIs (body, hat, head, arms, legs) from info->m_parts + // - Apply textures and colors from g_actorInfo + // - Store in m_characters under p_uniqueName + // - Do NOT set info->m_roi (no global table side effect) + // ~100 lines, mostly copied from CreateActorROI lines 459-603 +} +``` + +This is one of only two modifications to existing LEGO1 code needed (the other being 2 extension hook lines in `LegoWorld::Enable()`). Multiple "pepper_mp_1", "pepper_mp_2" etc. ROIs can coexist independently. + +**Alternative (simpler but limiting)**: Enforce unique character selection per room (max 6 players, one per character). This avoids the problem entirely but limits room size and player choice. + +--- + +## Existing Systems to Reuse + +| System | Key Class/Function | File | How It's Reused | +|--------|-------------------|------|-----------------| +| Character visuals | `LegoCharacterManager::CreateCharacterClone(uniqueName, type)` | `legocharactermanager.h` (new) | Creates independent ROI clone for remote player | +| Character release | `LegoCharacterManager::ReleaseActor(name)` | `legocharactermanager.h:80` | Cleanup on disconnect | +| Character names | `LegoActor::GetActorName(id)` | `legoactor.h:73` | Maps actorId to character name string (pepper=1..laura=5 are playable; brickster=6 is NPC-only) | +| Position remote player | `OrientableROI::SetLocal2WorldWithWorldDataUpdate(mat)` | `orientableroi.h:35` | Sets remote player transform each frame | +| Read local position | `OrientableROI::GetLocal2World()` | `orientableroi.h:48` | Reads local player transform to broadcast | +| Get local player | `LegoOmni::GetInstance()->GetUserActor()` | `legomain.h:166` | Access local player entity | +| Get current world | `LegoOmni::GetInstance()->GetCurrentWorld()` | `legomain.h:163` | Check if player is in ISLE world | +| World's ROI list | `LegoWorld::GetROIList()` | `legoworld.h:119` | Add/remove remote player ROIs | +| 3D scene add | `VideoManager()->Get3DManager()->Add(*roi)` | `lego3dmanager.h` | Add ROI to 3D rendering pipeline. Signature: `BOOL Add(ViewROI&)` - takes a reference, so pointer must be dereferenced. Also: `BOOL Remove(ViewROI&)`, `BOOL Moved(ViewROI&)` | +| Walk animation data | `LegoLocomotionAnimPresenter::GetAnimation()` | `legoanimpresenter.h:109` | Get `LegoAnim*` walk cycle from ISLE world | +| Animation application | `LegoROI::ApplyAnimationTransformation(node, mat, time, roiMap)` | `legoroi.cpp:435` | Static function - animate any ROIs, no entity needed | +| Animation presenter lookup | `world->Find("LegoAnimPresenter", g_cycles[type][mood])` | `legoanimationmanager.cpp:2716` | Find walking animation presenter by character type + mood; names are e.g. `"CNs001xx"` (generic), `"CNs001Pe"` (Pepper), etc. from `g_cycles[11][17]` table | +| Animation cycle table | `g_cycles[11][17]` | `legoanimationmanager.cpp:59` | Maps character type + mood/speed tier to animation presenter names; index 0-3 = slow walk (speed 0.7), 4-6 = fast walk (speed 4.0), 7-9 = run | +| Game loop hook | `MxTickleManager::RegisterClient(client, interval)` | `mxticklemanager.h:47` | Register NetworkManager for per-frame updates | +| World enable/disable hook | `Extension::Call(HandleWorldEnable, world, enable)` | `legoworld.cpp` (new) | Hook in `LegoWorld::Enable()` fires when worlds are enabled/disabled; same pattern as `Extension::Call(HandleWorld)` in `LegoOmni::AddWorld()` | +| Player world speed | `LegoEntity::GetWorldSpeed()` | `legoentity.h:91` | Read local player speed for broadcast | +| Actor identity | `LegoActor::GetActorId()` | `legoactor.h:67` | Current player's character type (pepper/mama/etc.) | +| Vehicle ride animation (small) | `g_cycles[type][10]` via `world->Find("LegoAnimPresenter", name)` | `legoanimationmanager.cpp:59,2389` | Ride animation includes character pose + vehicle variant model in one animation tree. Character dynamically bound via variable table - any character works with any ride animation. | +| Vehicle ride animation map | Bike→`CNs001Bd`+"bikebd", SkateBoard→`CNs001sk`+"board", Motocycle→`CNs011Ni`+"motoni" | `legoanimationmanager.cpp:59-246` | Maps vehicle type to animation presenter name + required vehicle variant ROI name | +| Vehicle variant ROI list | `g_vehicles[7]` | `legoanimationmanager.cpp:48` | NPC vehicle variant names: bikebd, bikepg, bikerd, bikesy, motoni, motola, board | +| Vehicle ROI cloning | `CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE)` | `legocharactermanager.cpp:987` | Create vehicle ROI clone from LOD list. Works for all vehicles with known LOD names. | +| Vehicle entity ROI names | bike, moto, dunebugy, rcuser, jsuser, towtk, ambul | `legoroi.cpp:47-53`, `isle.cpp:860`, `legoact2.cpp:287` | Entity ROI names for player-drivable vehicles (rcuser excluded from multiplayer - leaves ISLE) | +| Detect vehicle enter | `UserActor()->IsA("Helicopter")` etc. or compare `UserActor()` pointer | `islepathactor.cpp:94` | `UserActor()` changes to vehicle instance on enter, restored on exit | +| INI config loading | `iniparser_getseckeys(dict, section, ...)` loop in `IsleApp::LoadConfig()` | `isleapp.cpp:1251-1267` | Automatically discovers and loads options for any extension in `availableExtensions[]`. No code changes to isleapp needed. | + +--- + +## Architecture + +### Component Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ IsleApp Game Loop │ +│ SDL_AppIterate → Tick → MxTickleManager::Tickle() │ +└────────────────────────┬────────────────────────────────┘ + │ Tickle() every frame +┌────────────────────────▼────────────────────────────────┐ +│ NetworkManager : MxCore │ +│ │ +│ Every SEND_INTERVAL (~66ms / 15Hz): │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ BroadcastLocalState() │ │ +│ │ 1. Check if in ISLE world │ │ +│ │ 2. Read UserActor()->GetROI()->GetLocal2World()│ │ +│ │ 3. Read UserActor()->GetWorldSpeed() │ │ +│ │ 4. Serialize STATE packet │ │ +│ │ 5. transport->Send(packet) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ Every frame: │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ ProcessIncomingPackets() │ │ +│ │ For each received packet: │ │ +│ │ - JOIN: create RemotePlayer, spawn ROI │ │ +│ │ - LEAVE: destroy RemotePlayer, release ROI │ │ +│ │ - STATE: update RemotePlayer target transform │ │ +│ └─────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ UpdateRemotePlayers(deltaTime) │ │ +│ │ For each RemotePlayer: │ │ +│ │ 1. Interpolate position toward target │ │ +│ │ 2. Apply interpolated transform to ROI │ │ +│ │ 3. Advance & apply walking animation │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ NetworkTransport (abstract interface) │ │ +│ │ Connect() / Disconnect() / Send() / Receive() │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ WebSocketTransport (Emscripten, MVP) │ │ │ +│ │ │ - Relay URL from INI: multiplayer:relay url│ │ │ +│ │ │ - Binary messages via EM_JS interop │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ Future: UDPTransport, WebRTCTransport │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### Remote Player Lifecycle + +``` +Network EVENT RemotePlayer STATE What happens in game +───────────────────────────────────────────────────────────────────────────── +JOIN packet received → CREATED CreateCharacterClone() + → builds textured ROI + Add ROI to 3D scene + Build animation roiMap + Set visibility = FALSE + +First STATE with → VISIBLE Set visibility = TRUE +worldId == e_act1 Begin interpolation + +STATE with → VISIBLE, MOVING Interpolate position +speed > 0 Advance walk animation + Apply animation to ROI + +STATE with → VISIBLE, IDLE Interpolate to final pos +speed == 0 Reset anim to frame 0 + (standing pose) + +STATE with → VISIBLE, IN SMALL VEHICLE Swap to ride animation +vehicleType 3-5 (g_cycles[type][10]) +(bike/skate/moto) Animate character riding pose + + vehicle variant model together + Vehicle ROI via CreateAutoROI + +STATE with → VISIBLE, IN LARGE VEHICLE Hide character ROI +vehicleType 0-2,6-7 Show cloned vehicle ROI +(heli/jet/db/ Position at interpolated + tw/amb) transform + +STATE with → VISIBLE, ON FOOT Show character ROI again +vehicleType == -1 Hide/release vehicle ROI +(from vehicle) Swap back to walk animation + +STATE with → HIDDEN Set visibility = FALSE +worldId != e_act1 Stop animation + +Local player → (all) REMOVED FROM SCENE ViewManager::RemoveAll(NULL) is +leaves ISLE world called by LegoWorld::Enable(FALSE) + which removes ALL ROIs from scene. + ROI objects survive in memory (we + hold pointers in RemotePlayer). + +Local player → (all matching worldId) Must RE-ADD ROIs to ViewManager +returns to ISLE RE-ADDED TO SCENE via VideoManager()->Get3DManager() + ->Add(*roi) for each RemotePlayer. + Then restore visibility for peers + that are in ISLE. + +LEAVE packet or → DESTROYED Remove ROI from 3D scene +timeout (no STATE ReleaseActor(uniqueName) +for >5 seconds) Delete RemotePlayer +``` + +**IMPORTANT: World transition ROI lifecycle (verified)** + +When the local player enters a building from ISLE: +1. `LegoWorld::Enable(FALSE)` is called on the ISLE world +2. This calls `ViewManager::RemoveAll(NULL)` which removes ALL ROIs from the 3D scene graph +3. ROIs are NOT deleted - only removed from rendering (LOD level reset, mesh detached from D3DRM scene) +4. The ISLE world object persists in memory (it is NOT destroyed during normal gameplay) + +When the local player returns to ISLE: +1. `LegoWorld::Enable(TRUE)` is called on the ISLE world +2. This re-adds ONLY entity ROIs from `m_entityList` to the ViewManager (lines 708-720 in legoworld.cpp) +3. Remote player ROIs are NOT entities, so they are NOT automatically re-added + +**Solution: Extension hook in `LegoWorld::Enable()`** (following SiLoader pattern) + +Rather than polling `GetCurrentWorld()` in `Tickle()`, we add a proper extension hook in `LegoWorld::Enable()` that fires when any world is enabled or disabled. This follows the same `Extension::Call()` pattern used by SiLoader (e.g., `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()`). + +**New hook point in `LegoWorld::Enable()` (`legoworld.cpp`):** + +```cpp +// In LegoWorld::Enable(), enable path, after entity ROIs are re-added (after line 720): +Extension::Call(HandleWorldEnable, this, TRUE); + +// In LegoWorld::Enable(), disable path, after ViewManager::RemoveAll (after line 817): +Extension::Call(HandleWorldEnable, this, FALSE); +``` + +**Handler in Multiplayer extension:** + +```cpp +// extensions/include/extensions/multiplayer.h +class Multiplayer { +public: + static void Initialize(); + static void HandleWorldEnable(LegoWorld* p_world, MxBool p_enable); + + static std::map options; + static bool enabled; + + // Parsed from INI config during Initialize() + static std::string relayUrl; // from "multiplayer:relay url" + + // Called by NetworkManager to register itself + static void SetNetworkManager(NetworkManager* mgr); +private: + static NetworkManager* s_networkManager; +}; +``` + +```cpp +// extensions/src/multiplayer.cpp +void Multiplayer::Initialize() +{ + // Read relay server URL from INI config (required for Emscripten WebSocket transport) + relayUrl = options["multiplayer:relay url"]; + if (relayUrl.empty()) { + SDL_Log("Multiplayer: no relay url configured, multiplayer will not connect"); + } +} + +void Multiplayer::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable) +{ + if (!s_networkManager) return; + + // Check if this is the ISLE world (worldId == e_act1 for Act1/Isle) + if (p_enable) { + // ISLE re-enabled: re-add all remote player ROIs to ViewManager + s_networkManager->OnWorldEnabled(p_world); + } + else { + // ISLE disabled: ROIs already stripped by RemoveAll, just update state + s_networkManager->OnWorldDisabled(p_world); + } +} +``` + +This is much cleaner than polling - the hook fires at exactly the right moment, after entity ROIs are restored (enable) or after all ROIs are stripped (disable). Only two lines added to core LEGO1 code. + +### RemotePlayer Class Design + +```cpp +class RemotePlayer { +public: + RemotePlayer(uint32_t peerId, uint8_t actorId); + ~RemotePlayer(); + + void Spawn(LegoWorld* isleWorld); // Create ROI + animation setup + void Despawn(); // Release ROI + cleanup + void UpdateFromNetwork(const PlayerStateMsg& msg); // Store target state + void Tick(float deltaTime); // Interpolate + animate each frame + +private: + // Identity + uint32_t m_peerId; + uint8_t m_actorId; + char m_uniqueName[32]; // "pepper_mp_" + + // Visual + LegoROI* m_roi; // From CreateCharacterClone() + bool m_spawned; + bool m_visible; + + // Network state (latest received) + Mx3DPointFloat m_targetPosition; + Mx3DPointFloat m_targetDirection; + Mx3DPointFloat m_targetUp; + float m_targetSpeed; + int8_t m_targetVehicleType; // -1 = on foot, 0-7 = vehicle type encoding + int8_t m_targetWorldId; // LegoOmni::World enum; e_act1 = in ISLE + uint32_t m_lastUpdateTime; // For timeout detection + + // Interpolation state + MxMatrix m_currentTransform; // Smoothly approaches target + float m_interpAlpha; // 0..1, reset on each network update + + // Animation state (local only, not networked) + LegoAnim* m_walkAnim; // Pointer to ISLE world's walk cycle + LegoROI** m_walkRoiMap; // Maps walk anim nodes → body part ROIs + MxU32 m_walkRoiMapSize; + LegoAnim* m_rideAnim; // Pointer to ride animation for small vehicles (or NULL) + LegoROI** m_rideRoiMap; // Maps ride anim nodes → body + vehicle variant ROIs + MxU32 m_rideRoiMapSize; + LegoROI* m_vehicleROI; // Cloned vehicle ROI for large vehicles (or NULL) + int8_t m_currentVehicleType; // Currently active vehicle (-1 = on foot) + float m_animTime; // Local accumulator, advances with speed + bool m_wasMoving; // Track start/stop transitions +}; +``` + +### Walking Animation - Detailed Design + +The local player is always in 1st person and has no visible walking animation on themselves. But other players' characters need visible limb movement to look natural. The existing NPC animation system is fully reusable. + +**How NPC walking animation works (existing code):** + +1. `LegoLocomotionAnimPresenter` instances in the ISLE world hold `LegoAnim*` trees - these are hierarchical keyframe data describing how each body part (arms, legs, etc.) moves over a walk cycle duration +2. NPCs discover them via `world->Find("LegoAnimPresenter", presenterName)` where `presenterName` comes from `g_cycles[characterType][moodIndex]` (e.g. `"CNs001xx"` for generic slow walk). The `LegoAnimationManager::FUN_10063b90()` function (`legoanimationmanager.cpp:2382`) loads multiple speed tiers per character: slow walk at speed 0.7, fast walk at speed 4.0, etc. +3. `CreateROIAndBuildMap()` builds a `roiMap[]` array mapping animation tree node indices to the actor's body part ROIs, matched by **name** (body, head, arm-lft, arm-rt, leg-lft, leg-rt, etc.) +4. Each frame, `AnimateWithTransform()` calls the **static** `LegoROI::ApplyAnimationTransformation(node, parentTransform, time, roiMap)` which recursively computes `childROI.m_local2world = localAnimTransform * parentTransform` for each body part +5. NPC animation time advances as: `m_actorTime += deltaTime * m_worldSpeed` (`legopathactor.cpp:359`) +6. Cyclic time is: `timeInCycle = m_actorTime % duration` (`legoanimactor.cpp:61`) + +**Why this works directly for remote player ghost ROIs:** + +- `ApplyAnimationTransformation` is **static** (`legoroi.cpp:435`) - it needs only `(treeNode, matrix, time, roiMap)`, no entity/actor +- Cloned ROIs from `CreateCharacterClone()` have child ROIs with the **same body part names** (both use `g_actorLODs[]`: body, infohat, head, arm-lft, arm-rt, claw-lft, claw-rt, leg-lft, leg-rt) +- The walking `LegoAnim*` data is already loaded in the ISLE world's presenters +- ROI map index assignment is deterministic by tree traversal order + +**Animation state machine per RemotePlayer (local only, not networked):** + +``` + ┌──────────────┐ + speed > 0 │ STANDING │ speed == 0 (initial) + ┌──────────────────▶│ │◀─────────────────────┐ + │ │ animTime = 0 │ │ + │ │ Apply frame 0│ │ + │ └──┬───────┬───┘ │ + │ │ │ vehicleType >= 0 │ + │ speed > 0 │ ▼ │ + │ │ ┌──────────────────────┐ │ + │ │ │ IN SMALL VEHICLE │ │ + │ │ │ (bike/skate/moto) │ │ + │ │ │ │── vt<0─┘ + │ │ │ Use ride anim + map │ + │ │ │ animTime += dt*speed │ + │ │ │ Character visible │ + │ │ │ on vehicle │ + │ │ └──────────────────────┘ + │ │ + │ │ ┌──────────────────────┐ + │ │ │ IN LARGE VEHICLE │ + │ │ │ (heli/jet/db/etc.) │── vt<0─┘ + │ │ │ │ + │ │ │ Character ROI hidden │ + │ │ │ Vehicle ROI shown │ + │ │ │ Position vehicle at │ + │ │ │ interpolated transform │ + │ │ └──────────────────────┘ + │ ▼ + │ ┌──────────────┐ + │ │ WALKING │ + │ │ │──── speed == 0 ──────┘ + │ │ Each frame: │ + └────────────────────│ animTime += │ + │ dt * speed │ + │ Apply walk │ + │ at time % │ + │ duration │ + └──────────────┘ +``` + +- **STANDING**: `m_animTime = 0`. Apply `ApplyAnimationTransformation` at time 0, which is the neutral standing pose (arms at sides, legs together). This is applied once when entering this state. +- **WALKING**: `m_animTime += deltaTime * receivedSpeed`. Compute `timeInCycle = m_animTime % walkDuration`. Apply `ApplyAnimationTransformation` at `timeInCycle` every frame. The animation naturally loops - legs swing, arms pump. +- **IN SMALL VEHICLE** (Bike, SkateBoard, Motocycle): Same `ApplyAnimationTransformation` pipeline as walking, but using `m_rideAnim` + `m_rideRoiMap` instead of walk anim. Character ROI remains visible (in riding pose). Vehicle variant ROI (e.g., "bikebd") is part of the roiMap and animates together with the character. +- **IN LARGE VEHICLE** (Helicopter, Jetski, DuneBuggy, TowTrack, Ambulance): Character ROI hidden. A cloned vehicle ROI (`m_vehicleROI`) is positioned at the interpolated transform. No animation playback - just position/rotation updates. (RaceCar excluded - leaves ISLE world.) +- **Transition WALKING→STANDING**: Reset `m_animTime = 0`, apply frame 0. There may be a small visual snap from mid-stride to standing, but this is acceptable - the position interpolation has already stopped the character's movement, so the snap is brief and natural. + +**No network sync of animation state is needed** because: +- Walk cycles are purely cosmetic - exact limb positions don't matter for gameplay +- NPCs in the original game don't sync animation state with each other either +- Speed-based local advancement produces natural-looking results +- Each client independently advances its own timers + +**Implementation (~70-80 lines in RemotePlayer):** +```cpp +// === One-time setup during Spawn() === + +// 1. Find walking animation in ISLE world (~5 lines) +// Walking animation presenter names come from g_cycles[11][17] table +// (legoanimationmanager.cpp:59). Use the generic cycle set (index 0): +// g_cycles[0][0] = "CNs001xx" (slow walk, speed 0.7) +// g_cycles[0][4] = "CNs005xx" (fast walk, speed 4.0) +// For character-specific animations, use the character's cycle set index. +LegoLocomotionAnimPresenter* walkPresenter = + (LegoLocomotionAnimPresenter*) isleWorld->Find("LegoAnimPresenter", "CNs001xx"); +m_walkAnim = walkPresenter->GetAnimation(); + +// 2. Build ROI map for this remote player's body parts (~30-40 lines) +// Walk animation tree, match node names to child ROIs of m_roi +// (same logic as BuildROIMap but operating on our cloned ROI hierarchy) +m_roiMap = BuildRemotePlayerROIMap(m_walkAnim, m_roi, &m_roiMapSize); + +// === Per-frame in Tick() === + +// 3. Determine animation state from received speed +if (m_targetSpeed > 0.01f) { + // WALKING: advance local animation time + m_animTime += deltaTime * m_targetSpeed; + float duration = m_walkAnim->GetDuration(); + float timeInCycle = m_animTime - duration * ((int)(m_animTime / duration)); + + MxMatrix transform(m_roi->GetLocal2World()); + LegoTreeNode* root = m_walkAnim->GetRoot(); + for (int i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation( + root->GetChild(i), transform, timeInCycle, m_roiMap); + } + m_wasMoving = true; +} +else if (m_wasMoving) { + // WALKING → STANDING transition: snap to frame 0 (standing pose) + m_animTime = 0.0f; + MxMatrix transform(m_roi->GetLocal2World()); + LegoTreeNode* root = m_walkAnim->GetRoot(); + for (int i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation( + root->GetChild(i), transform, 0.0f, m_roiMap); + } + m_wasMoving = false; +} +// else: already standing, no animation update needed +``` + +### Vehicle Support for Remote Players (MUST HAVE) + +When a player enters a vehicle, other players MUST see the vehicle (and character riding pose for small vehicles). This is essential for MVP - without it, players randomly disappear and reappear which is confusing. + +**Three categories of vehicles exist in LEGO Island:** + +| Category | Vehicles | In ISLE World? | Ride Animation? | Approach | +|----------|----------|----------------|-----------------|----------| +| Small (open) | Bike, SkateBoard, Motocycle | YES - free driving | YES - NPC ride animations at `g_cycles[type][10]` | Show character riding vehicle using existing animation system | +| Large (enclosed) | Helicopter, DuneBuggy, TowTrack, Ambulance, Jetski | YES - free driving/missions | NO - first-person only in original game | Show vehicle model only (character hidden inside) | +| Race-only | RaceCar | NO - enters CarRace_World | NO | Not needed for multiplayer (player leaves ISLE) | + +**Verified by code analysis:** +- **RaceCar** confirmed leaves ISLE: `HandleClick()` does NOT call `Enter()`, sets `e_carrace`, `SwitchArea()` opens `CarRace_World` via `InvokeAction(e_opendisk, *g_carraceScript)` (`legogamestate.cpp:953`) +- **Jetski** confirmed stays in ISLE: `HandleClick()` calls `Enter()` (line 122), registers with ControlManager; `SwitchArea(e_jetski)` just calls `LoadIsle()` (`legogamestate.cpp:906`). There IS also a separate Jet Race (`e_jetrace`) but free riding is in ISLE. +- **Ambulance** confirmed stays in ISLE: `HandleClick()` calls `Enter()` (line 389), mission runs within ISLE world (picking up patients); state handled by `Act1State::e_ambulance` within Isle's notification handler. +- All other vehicles (Bike, SkateBoard, Motocycle, DuneBuggy, Helicopter, TowTrack) call `Enter()` and stay in ISLE. + +#### Character × Vehicle Animation Compatibility (Verified) + +**All 5 playable characters (Pepper, Mama, Papa, Nick, Laura) CAN ride ANY small vehicle.** + +The animation system dynamically binds the character via a variable table (`legoanimpresenter.cpp:1442`): +```cpp +variableTable->SetVariable(key, p_actor->GetROI()->GetName()); +``` +The root animation node is a variable resolved to the character's ROI name at runtime. The vehicle ROI name is hardcoded in the animation data tree. This means: +- **Character is swappable** - any character ROI can be paired with any ride animation +- **Vehicle is fixed per animation** - each animation references a specific vehicle variant ROI (e.g., "bikebd", "board", "motoni") + +For multiplayer, we choose the ride animation based on the VEHICLE TYPE (not the character), and the character binds automatically. + +**Available ride animation presenters per vehicle type:** + +| Vehicle Type | Animation Presenter | Vehicle Variant ROI | Notes | +|-------------|-------------------|-------------------|-------| +| Bike | `CNs001Bd` (from `g_cycles[7][10]`) | `bikebd` | Any of CNs001Bd/Pg/Rd/Sy work; differ only in vehicle variant | +| SkateBoard | `CNs001sk` (from `g_cycles[1][10]`) | `board` | Pepper's cycle set, but character is swappable | +| Motorcycle | `CNs011Ni` (from `g_cycles[4][10]`) | `motoni` | Nick's cycle set, but character is swappable | + +**How NPC vehicle riding works (existing code, verified):** + +1. Each NPC character has a `m_vehicleId` in `g_characters[47]` (`legoanimationmanager.cpp:251`), mapping to `g_vehicles[]` (e.g., Pepper → "board"/skateboard, Nick → "motoni"/motorcycle) +2. The vehicle animation is at `g_cycles[type][10]` (e.g., `"CNs001sk"` for Pepper on skateboard). This is a `LegoLocomotionAnimPresenter` that contains **both** the character's riding pose AND the vehicle model in one unified animation tree +3. `FUN_10063b90()` (`legoanimationmanager.cpp:2382`) loads it via `CreateROIAndBuildMap(actor, 1.7f)` at speed 1.7 +4. `CreateSceneROIs()` (`legoanimpresenter.cpp:301`) finds/creates the vehicle ROI in the scene (e.g., calls `FindROI("board")` or `CreateAutoROI()`) +5. `BuildROIMap()` maps animation tree nodes to **both** character body parts AND the vehicle ROI +6. `ApplyAnimationTransformation()` then animates the whole assembly - character in riding pose + vehicle moving together + +**This means vehicle animation is handled by the SAME `ApplyAnimationTransformation()` pipeline as walking.** The only difference is which `LegoAnim*` and `roiMap[]` are used. + +#### Vehicle ROI Names and Cloning Strategy + +**Vehicle entity ROI names for ISLE-world vehicles (from SI data / source code references):** + +| Vehicle | Entity Class | Entity ROI Name | LOD in ViewLODListManager? | Cloning Method | +|---------|-------------|----------------|---------------------------|----------------| +| Bike | `Bike` | `bike` | YES (`g_sharedModelsHigh`) | `CreateAutoROI("bike_mp_N", "bike", FALSE)` | +| Motocycle | `Motocycle` | `moto` | YES (`g_sharedModelsHigh`) | `CreateAutoROI("moto_mp_N", "moto", FALSE)` | +| DuneBuggy | `DuneBuggy` | `dunebugy` | YES (`g_alwaysLoadNames`) | `CreateAutoROI("dunebugy_mp_N", "dunebugy", FALSE)` | +| Jetski | `Jetski` | `jsuser` | YES (`g_alwaysLoadNames`) | `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` | +| TowTrack | `TowTrack` | `towtk` | After SI load | `CreateAutoROI("towtk_mp_N", "towtk", FALSE)` | +| Ambulance | `Ambulance` | `ambul` | After SI load | `CreateAutoROI("ambul_mp_N", "ambul", FALSE)` | +| Helicopter | `Helicopter` | from SI data | After SI load | `CreateAutoROI` with discovered name | +| SkateBoard | `SkateBoard` | from SI data | After SI load | `CreateAutoROI` with discovered name | + +Note: RaceCar (`rcuser`) excluded - transitions to CarRace_World, not ridden in ISLE. + +Source: `g_sharedModelsHigh` and `g_alwaysLoadNames` in `legoroi.cpp:47-53`; `SetROIVisible("towtk")` in `isle.cpp:860`; `FindROI("ambul")` in `legoact2.cpp:287`. + +**NPC vehicle variant ROI names (for ride animations):** + +| NPC Vehicle ROI | LOD Name | Used by Animation | +|----------------|----------|-------------------| +| `bikebd` | `bikebd` | CNs001Bd (bike ride) | +| `bikepg` | `bikepg` | CNs001Pg (bike ride) | +| `bikerd` | `bikerd` | CNs001Rd (bike ride) | +| `bikesy` | `bikesy` | CNs001Sy (bike ride) | +| `motoni` | `motoni` | CNs011Ni (motorcycle ride) | +| `motola` | `motola` | CNs011La (motorcycle ride) | +| `board` | `board` | CNs001sk (skateboard ride) | + +These are DIFFERENT objects from the player vehicle entities. NPC variants are created by `CreateAutoROI()` during animation setup (`legoanimpresenter.cpp:326`). For multiplayer, we use these same NPC variant ROIs for ride animations, and create clones via `CreateAutoROI()` if multiple remote players ride the same vehicle type. + +**Cloning approach - `CreateAutoROI` (`legocharactermanager.cpp:987`):** +```cpp +// Creates a new ROI from a LOD list name in ViewLODListManager +// Works for single-mesh vehicle models +// Adds to 3D scene and m_characters map automatically +LegoROI* roi = CharacterManager()->CreateAutoROI("bikebd_mp_42", "bikebd", FALSE); +``` + +**Why NOT CreateAutoROI for character cloning:** +- Characters are hierarchical multi-part ROIs: root + 10 child ROIs (body, head, arm-lft, arm-rt, leg-lft, leg-rt, claw-lft, claw-rt, infohat) from `g_actorLODs[]` +- Each child has its own LOD list, texture, and color customization +- `CreateAutoROI` creates a single-level ROI from ONE LOD list - no hierarchy +- Characters still need `CreateCharacterClone()` which copies the `CreateActorROI()` hierarchy-building logic (~100 lines) + +#### Vehicle Rendering Implementation + +**What changes for remote player vehicle support:** + +1. **Protocol**: `vehicleType` field in STATE message (1 byte, see encoding below) +2. **Detecting local vehicle state**: Compare `UserActor()` pointer to previous value. When it changes, the player entered/exited a vehicle. Use `IsA("Helicopter")`, `IsA("Bike")`, etc. to determine vehicle type. +3. **Small vehicle (bike/skateboard/motorcycle) - show character riding:** + - Find the ride animation presenter for the vehicle type (e.g., `world->Find("LegoAnimPresenter", "CNs001Bd")` for bike) + - Build roiMap binding the remote player's character ROI + a vehicle variant ROI + - Vehicle variant ROI created via `CreateAutoROI("bikebd_mp_N", "bikebd", FALSE)` if clone needed + - Animate using same `ApplyAnimationTransformation()` pipeline as walking +4. **Large vehicle (helicopter/jetski/etc.) - show vehicle only:** + - Hide the remote player's character ROI (`SetVisibility(FALSE)`) + - Create vehicle ROI clone via `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` (example for Jetski; use appropriate vehicle name per type) + - Position vehicle ROI at remote player's interpolated transform + - No animation needed (vehicle just moves/rotates) +5. **On vehicle exit**: Hide vehicle ROI, show character ROI, switch back to walk animation + +**Vehicle type encoding for protocol (only vehicles ridden in ISLE world):** +``` +-1 = on foot (walking/standing) + 0 = Helicopter (large, vehicle-only rendering) + 1 = Jetski (large, vehicle-only rendering) + 2 = DuneBuggy (large, vehicle-only rendering) + 3 = Bike (small, ride animation via CNs001Bd + "bikebd") + 4 = SkateBoard (small, ride animation via CNs001sk + "board") + 5 = Motocycle (small, ride animation via CNs011Ni + "motoni") + 6 = TowTrack (large, vehicle-only rendering) + 7 = Ambulance (large, vehicle-only rendering) +``` +Note: RaceCar is excluded because clicking it transitions to CarRace_World (separate world, player leaves ISLE). + +**Effort assessment**: Medium. The animation pipeline is identical for small vehicles - we're just swapping which `LegoAnim*` + `roiMap[]` is active. Large vehicles are simpler (just position a model). The main work is: +- Building the vehicle roiMap for small vehicles (lazily on first vehicle state) - ~30 lines +- Detecting vehicle enter/exit on local player via `UserActor()` comparison - ~10 lines +- Vehicle ROI cloning via `CreateAutoROI()` per vehicle type - ~20 lines +- Swapping animation state in `RemotePlayer::Tick()` - ~15 lines +- Large vehicle ROI creation/positioning - ~20 lines + +--- + +## Wire Protocol + +``` +Header (all messages): + uint8_t type; // JOIN=1, LEAVE=2, STATE=3 + uint32_t peerId; // assigned by relay server + uint32_t sequence; // monotonic counter for ordering/staleness + +STATE message (~49 bytes, sent at 10-15 Hz): + uint8_t actorId; // LegoActor enum (pepper=1..laura=5; brickster=6 is NPC-only, never sent) + int8_t worldId; // LegoOmni::World enum (-1=undefined, 0=e_act1/ISLE, 1=e_imain, etc.) + int8_t vehicleType; // -1 = on foot, 0-7 = vehicle type (see Vehicle Type Encoding) + float position[3]; // from m_local2world[3] (world position, 12 bytes) + float direction[3];// from m_local2world[2] (facing direction, 12 bytes) + float up[3]; // from m_local2world[1] (up vector, 12 bytes) + float speed; // m_worldSpeed (0 = standing, >0 = walking/running) + +JOIN message (reliable, on connect): + uint8_t actorId; // pepper=1..laura=5 (validated on receive; reject if out of range) + char name[20]; // player display name + +LEAVE message (reliable, on disconnect): + (header only) +``` + +Remote players are only visible when **both** the local and remote player are in the ISLE world (`worldId == e_act1`). When either enters an instanced area (buildings, races, cutscenes), the remote player's ghost is hidden. The `worldId` field uses the `LegoOmni::World` enum directly, which is useful for logging/debugging (e.g., "player entered e_hosp" vs just "player left ISLE"). + +Position+direction+up (9 floats, 36 bytes) rather than full 4x4 matrix because: +- Maps directly to `LegoEntity::SetWorldTransform(location, direction, up)` +- 12 bytes smaller than full 4x3 matrix +- The right vector can be reconstructed as `cross(up, direction)` + +At 15Hz with 4 peers: 48 bytes * 15 * 4 = ~2.9 KB/s total - negligible. + +--- + +## Relay Server: Cloudflare Worker + Durable Object + +The relay server is a Cloudflare Worker using a Durable Object per room. This gives globally distributed edge deployment, built-in WebSocket support, no server management, and a generous free tier. + +**Architecture:** +- Each "room" is a **Durable Object** instance that holds the set of connected WebSocket peers +- The Worker's `fetch` handler routes `wss:///room/` to the correct Durable Object +- The Durable Object accepts WebSocket upgrades, tracks connected peers, and broadcasts incoming binary messages to all other peers in the room +- Room lifetime is automatic: Durable Object hibernates when all peers disconnect + +**Sketch (`extensions/src/multiplayer/server/src/index.ts`, ~80 lines):** +```typescript +export class GameRoom implements DurableObject { + private peers: Map = new Map(); + + async fetch(request: Request): Promise { + const [client, server] = Object.values(new WebSocketPair()); + const peerId = crypto.randomUUID(); + this.ctx.acceptWebSocket(server); + server.serializeAttachment({ peerId }); + this.peers.set(peerId, server); + // Notify others of join + this.broadcast(server, joinMsg(peerId)); + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, data: ArrayBuffer | string) { + // Relay binary STATE messages to all other peers + this.broadcast(ws, data); + } + + async webSocketClose(ws: WebSocket) { + const { peerId } = ws.deserializeAttachment(); + this.peers.delete(peerId); + this.broadcast(ws, leaveMsg(peerId)); + } + + private broadcast(sender: WebSocket, data: any) { + for (const [, peer] of this.peers) { + if (peer !== sender && peer.readyState === WebSocket.OPEN) { + peer.send(data); + } + } + } +} + +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + const match = url.pathname.match(/^\/room\/([a-z0-9-]+)$/); + if (!match) return new Response("Not found", { status: 404 }); + const roomId = env.GAME_ROOM.idFromName(match[1]); + const room = env.GAME_ROOM.get(roomId); + return room.fetch(request); + } +}; +``` + +**`wrangler.toml`:** +```toml +name = "isle-multiplayer" +main = "src/index.ts" + +[[durable_objects.bindings]] +name = "GAME_ROOM" +class_name = "GameRoom" + +[[migrations]] +tag = "v1" +new_classes = ["GameRoom"] +``` + +**Client connection URL**: Configured via INI as `multiplayer:relay url` (e.g., `wss://isle-multiplayer..workers.dev`). Room ID is appended at connect time: `/room/`. + +**Benefits:** +- Zero ops: no server to provision, scale, or maintain +- Global edge: WebSocket terminates at nearest Cloudflare PoP +- Free tier: 100k requests/day, 1M Durable Object requests/month (ample for MVP) +- Hibernation API: Durable Object sleeps when no peers are connected (zero cost when idle) +- Self-hostable: anyone can deploy their own relay by running `wrangler deploy` + +--- + +## INI Configuration + +The multiplayer extension follows the same INI config pattern as SiLoader and TextureLoader. The extension is enabled/disabled in the `[extensions]` section, and its options live in a dedicated `[multiplayer]` section. + +**INI file (`isle.ini`):** +```ini +[extensions] +multiplayer=true + +[multiplayer] +relay url=wss://isle-multiplayer.example.workers.dev +``` + +**How it works** (no changes to `isleapp.cpp` config loading needed): + +The existing extension loading loop in `IsleApp::LoadConfig()` (`isleapp.cpp:1251-1267`) already handles this automatically: +1. Checks `iniparser_getboolean(dict, "extensions:multiplayer", 0)` - is multiplayer enabled? +2. Reads all keys from the `[multiplayer]` section via `iniparser_getseckeys()` +3. Builds `std::map` with entries like `{"multiplayer:relay url", "wss://..."}` +4. Calls `Extensions::Enable("extensions:multiplayer", options)` +5. `Multiplayer::options` is set, `Multiplayer::Initialize()` parses the relay URL + +**INI keys:** + +| INI Section | Key | Type | Required | Description | +|------------|-----|------|----------|-------------| +| `extensions` | `multiplayer` | boolean | Yes | Enable/disable multiplayer extension | +| `multiplayer` | `relay url` | string | Yes (for MVP) | WebSocket relay server base URL. Room ID appended as `/room/` | + +**CONFIG app changes**: Add multiplayer toggle + relay URL input field to the Qt configuration app (`CONFIG/qt/config.cpp`), following the same pattern as the texture loader path and SI loader files fields. + +--- + +## Transport Abstraction + +The networking layer uses an abstract transport interface so that future transport implementations (WebRTC, native UDP, etc.) can be swapped in without changing any game logic. **For MVP, only the Emscripten WebSocket transport is implemented.** + +```cpp +// extensions/include/extensions/multiplayer/networktransport.h +class NetworkTransport { +public: + virtual ~NetworkTransport() = default; + + // Lifecycle + virtual void Connect(const char* roomId) = 0; + virtual void Disconnect() = 0; + virtual bool IsConnected() const = 0; + + // Send binary data to all peers (via relay or direct) + virtual void Send(const uint8_t* data, size_t length) = 0; + + // Drain received messages. Returns number of messages dequeued. + // Callback signature: void(const uint8_t* data, size_t length) + virtual size_t Receive(std::function callback) = 0; +}; +``` + +**MVP implementation: `WebSocketTransport` (Emscripten-only)** + +```cpp +// extensions/src/multiplayer/websockettransport.h +class WebSocketTransport : public NetworkTransport { +public: + WebSocketTransport(const std::string& relayBaseUrl); + + void Connect(const char* roomId) override; + void Disconnect() override; + bool IsConnected() const override; + void Send(const uint8_t* data, size_t length) override; + size_t Receive(std::function callback) override; + +private: + std::string m_relayBaseUrl; // From INI: "multiplayer:relay url" + // JS-side WebSocket handle via EM_JS interop + // Incoming messages queued in JS ArrayBuffer queue, drained in Receive() +}; +``` + +**Transport is created in `Multiplayer::Initialize()` based on platform:** +```cpp +void Multiplayer::Initialize() +{ + relayUrl = options["multiplayer:relay url"]; + +#ifdef __EMSCRIPTEN__ + if (!relayUrl.empty()) { + s_transport = std::make_unique(relayUrl); + } +#else + // Future: native transports (UDP, WebRTC via native lib, etc.) + SDL_Log("Multiplayer: no transport available for this platform yet"); +#endif +} +``` + +`NetworkManager` receives the transport via `Multiplayer::GetTransport()` and is completely transport-agnostic - it only calls `Send()`, `Receive()`, `Connect()`, `Disconnect()`. + +**Future transport implementations** (not MVP): + +| Transport | Platform | Use Case | Relay Needed? | +|-----------|----------|----------|---------------| +| `WebSocketTransport` | Emscripten | **MVP** - browser builds | Yes (Cloudflare Worker) | +| `WebRTCTransport` | Emscripten | P2P upgrade - lower latency | Only for signaling | +| `UDPTransport` | Desktop/Mobile | LAN multiplayer | No (direct/broadcast) | +| `ENetTransport` | Desktop/Mobile | Internet multiplayer | Optional relay | + +--- + +## New File Structure + +``` +extensions/ + include/extensions/ + multiplayer.h # Extension class: INI config, HandleWorldEnable hook, transport factory + multiplayer/ + networkmanager.h # MxCore subclass, manages session lifecycle (transport-agnostic) + networktransport.h # Abstract transport interface (Connect/Disconnect/Send/Receive) + websockettransport.h # Emscripten WebSocket implementation (MVP transport) + remoteplayer.h # Remote player ROI + interpolation + animation state + protocol.h # Wire protocol structs & serialization + src/ + multiplayer.cpp # Extension Enable/Initialize (INI parsing, transport creation) + HandleWorldEnable + multiplayer/ + networkmanager.cpp # Core tick logic, local state broadcast, remote player management + remoteplayer.cpp # ROI creation/destruction, interpolation, animation + protocol.cpp # Packet encode/decode + websockettransport.cpp # WebSocket transport (Emscripten via EM_JS, connects to relay URL from INI) + server/ # Cloudflare Worker relay server (self-contained, deployable) + src/ + index.ts # Cloudflare Worker entry point + Durable Object + wrangler.toml # Cloudflare Workers config + package.json +``` + +**CMakeLists.txt changes**: Add new sources under the existing `ISLE_EXTENSIONS` block. No new cmake option needed initially - multiplayer is a runtime feature within the existing extensions system. + +**Existing files modified**: +- `extensions/include/extensions/extensions.h` - Add `"extensions:multiplayer"` to `availableExtensions[]` +- `extensions/src/extensions.cpp` - Add `#include "extensions/multiplayer.h"` and enable handler (same pattern as SiLoader: set options, enabled flag, call Initialize) +- `LEGO1/lego/legoomni/src/entity/legoworld.cpp` - Add 2 `Extension::Call(HandleWorldEnable, ...)` lines in `Enable()` (enable path after line 720, disable path after line 817) +- `CONFIG/qt/config.cpp` - Add multiplayer toggle + relay URL input field (follows texture loader / SI loader pattern) + +Note: `isleapp.cpp` does NOT need modification for INI loading - the existing extension loading loop (`isleapp.cpp:1251-1267`) automatically picks up any extension registered in `availableExtensions[]`. + +**Additions to core LEGO1 code** (minimal, following existing extension patterns): +- `LegoCharacterManager::CreateCharacterClone()` (~100 lines) - new method for independent ROI clones +- `LegoWorld::Enable()` - 2 lines: `Extension::Call(HandleWorldEnable, this, TRUE/FALSE)` after entity ROI re-add (enable path) and after `RemoveAll` (disable path), same pattern as `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()` + +All other multiplayer code lives in `extensions/`. + +--- + +## Implementation Order + +| Phase | Task | Effort | Dependencies | Details | +|-------|------|--------|--------------|---------| +| 1 | Transport interface + WebSocket impl | Medium | None | `NetworkTransport` abstract interface (`Connect`, `Disconnect`, `Send`, `Receive`). `WebSocketTransport` (Emscripten-only MVP impl) uses `EM_JS` to create browser WebSocket, connects to relay URL from INI config (`multiplayer:relay url`), sends/receives binary `ArrayBuffer` messages. Incoming data buffered in JS queue, drained in C++ each frame. Future transports (UDP, WebRTC) implement the same interface. | +| 2 | Protocol structs + serialization | Small | None | `PlayerStateMsg`, `PlayerJoinMsg`, `PlayerLeaveMsg` packed structs. `Serialize()`/`Deserialize()` to/from byte buffers. Endianness handling (always little-endian on wire). | +| 3 | NetworkManager skeleton | Small | Phase 1 | `MxCore` subclass. Register with `MxTickleManager`. `Tickle()` calls `BroadcastLocalState()` at 15Hz interval + `ProcessIncomingPackets()` + `UpdateRemotePlayers()` every frame. | +| 4 | Local state broadcast | Small | Phase 3 | In `BroadcastLocalState()`: null-check `UserActor()` and `CurrentWorld()` first (both can be NULL during world transitions - `legogamestate.cpp:219`). If valid, read `UserActor()->GetROI()->GetLocal2World()` for position/direction/up, `GetWorldSpeed()` for speed, set `worldId` from `GetCurrentWorld()->GetWorldId()`, detect vehicle via `UserActor()->IsA("Helicopter")` etc., serialize and send. | +| 5 | `CreateCharacterClone()` | Medium | None | New method on `LegoCharacterManager`. ~100 lines copied from `CreateActorROI()` body construction loop. Takes `(uniqueName, characterType)`, creates independent multi-part ROI, stores in `m_characters` map under unique name, does NOT write to `g_actorInfo[]`. | +| 6 | RemotePlayer: ROI + interpolation | Medium | 2, 5 | `RemotePlayer` class. `Spawn()` calls `CreateCharacterClone()`, adds to 3D scene. `UpdateFromNetwork()` stores target transform. `Tick()` interpolates current transform toward target using lerp, applies via `SetLocal2WorldWithWorldDataUpdate()`. | +| 7 | RemotePlayer: walking animation | Small | 6 | In `Spawn()`: find walk presenter via `g_cycles[type][0]`, get `LegoAnim*`, build roiMap. In `Tick()`: if speed>0 advance `m_animTime`, compute `timeInCycle`, call `ApplyAnimationTransformation()`. If speed==0 and was moving, snap to frame 0. ~70-80 lines. | +| 7b | RemotePlayer: vehicle rendering (MUST HAVE) | Medium | 7 | **Small vehicles (type 3-5):** When `vehicleType` changes, swap active `LegoAnim*` + `roiMap[]` to ride animation. Ride anim from `g_cycles` (bike→`CNs001Bd`, skate→`CNs001sk`, moto→`CNs011Ni`). Character dynamically bound via variable table. Vehicle variant ROI (e.g., "bikebd") created via `CreateAutoROI()`. **Large vehicles (type 0-2,6-7):** Hide character ROI, create vehicle ROI clone via `CreateAutoROI()` (e.g., `CreateAutoROI("jsuser_mp_N", "jsuser", FALSE)` for Jetski), position at interpolated transform. On vehicle exit: show character, hide/release vehicle ROI, restore walk animation. | +| 8 | ISLE-world visibility | Small | 6 | Add `Extension::Call(HandleWorldEnable, this, p_enable)` hook in `LegoWorld::Enable()` (2 lines in core code, following SiLoader pattern). `HandleWorldEnable` calls `NetworkManager::OnWorldEnabled(world)` which re-adds all remote player ROIs via `Get3DManager()->Add(*roi)` and restores visibility. `OnWorldDisabled(world)` updates internal state. On remote `worldId` change: toggle visibility for that specific remote player. | +| 9 | Cloudflare Worker relay | Small | None | ~80 lines TypeScript in `extensions/src/multiplayer/server/`. Durable Object per room. Accept WebSocket, broadcast binary messages to other peers, emit JOIN/LEAVE on connect/disconnect. Deploy with `cd extensions/src/multiplayer/server && wrangler deploy`. | +| 10 | End-to-end test | - | All above | Two Emscripten instances in separate browser tabs. Connect to same room. Walk around. Verify: characters appear, move smoothly, animate, disappear on disconnect. | +| 11 | Extensions integration + UI | Small | 10 | Register `"extensions:multiplayer"` in `availableExtensions[]` and `extensions.cpp`. INI config provides relay URL. Room code input via HTML outside game canvas. JS calls `Module.ccall()` to trigger connect/disconnect. Player count display. | +| Future | WebRTC P2P upgrade | Large | 10 | Add `WebRTCTransport` implementation. Use Cloudflare Worker for signaling only (SDP/ICE exchange). Data flows P2P after connection established. | +| Future | Native UDP transport | Medium | 1 | Add `UDPTransport` for desktop builds. LAN broadcast for discovery. | + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `UserActor()` NULL during world transitions | **High** | Between `SetUserActor(NULL)` (`legogamestate.cpp:219`) and `SetUserActor(newActor)` (line 239), `GetUserActor()` returns NULL. `BroadcastLocalState()` MUST null-check both `UserActor()` and `CurrentWorld()` and skip if either is NULL. Same guard needed for vehicle detection. | +| `Get3DManager()->Add()` is NOT recursive | **Resolved** | `Add()` only pushes the parent ROI onto `ViewManager::rois`. However, `ViewManager::Update()` calls `ManageVisibilityAndDetailRecursively()` which traverses `GetComp()` children every frame, handling LOD and geometry attachment for all body parts automatically. This matches how the game works: `GetActorROI()` (`legocharactermanager.cpp:272`) and `LegoWorld::Enable(TRUE)` (`legoworld.cpp:708-720`) both add only the parent. Only one `Add(*roi)` call needed per remote player. | +| `GetWorldSpeed()` returns animation speed, not vehicle speed | **Medium** | When player is in a vehicle, `GetWorldSpeed()` is the actor's animation playback multiplier, not the vehicle's actual movement speed. For large vehicles (character hidden), read speed from the vehicle entity itself: `UserActor()->GetWorldSpeed()` works because `UserActor()` IS the vehicle during riding (`islepathactor.cpp:94`). | +| Emscripten WebSocket threading | Medium | All network I/O on main thread via `Tickle()`; browser WebSocket is natively async; incoming messages queued in JS, drained in C++ each frame. Existing interop uses `MAIN_THREAD_EM_ASM` pattern (not `EM_JS`); WebSocket impl should follow the same pattern. | +| ROI lifecycle management | Medium | Every `CreateCharacterClone()` paired with `ReleaseActor()`; tracked in `RemotePlayer`; `Despawn()` called in destructor as safety net. `ReleaseActor()` calls `RemoveROI()` → `Get3DManager()->Remove()` which DOES recursively clean children via `GetComp()`. | +| Entering/leaving ISLE world | **Medium** | `ViewManager::RemoveAll(NULL)` called on ISLE disable strips ALL ROIs from scene (not just ours). Handled cleanly via `Extension::Call(HandleWorldEnable, this, p_enable)` hook in `LegoWorld::Enable()`, following the same pattern as `Extension::Call(HandleWorld, p_world)` in `LegoOmni::AddWorld()`. The hook fires at exactly the right moment - after entity ROIs are restored (enable) or after all ROIs are stripped (disable). NetworkManager re-adds remote player ROIs via `Get3DManager()->Add(*roi)` in the enable callback. | +| Brickster actorId validation | Medium | Brickster (actorId=6) is NPC-only. Character selection (`legovariables.cpp:167-186`) only handles IDs 1-5. Vehicle code clamps to `c_laura` (5). Protocol must validate `actorId` is in range 1-5 on receive; reject packets with invalid actorId. | +| Character map name collisions | Low | `LegoCharacterManager`'s map uses case-insensitive comparison (`SDL_strcasecmp`). Multiplayer clone names (e.g., "pepper_mp_42") must be unique and cannot accidentally match existing names like "pepper". The `_mp_` suffix ensures this. | +| Walking animation ROI map construction | Low | Body part names are deterministic from `g_actorLODs[]`; `BuildRemotePlayerROIMap()` matches by name, same logic as existing `BuildROIMap()` | +| Walk→stand animation snap | Low | Resetting to frame 0 may cause a brief visual snap from mid-stride; acceptable because position has already stopped; could improve later by letting walk cycle complete to nearest frame-0 crossing | +| Animation presenters survive world transitions | Low | `LegoWorld::Enable(FALSE)` only toggles presenter `Enable` flags and stores them in `m_disabledObjects`. `Enable(TRUE)` re-enables them. Presenters and their `LegoAnim*` data remain valid in memory. Remote player animation pointers obtained during `Spawn()` remain valid across world transitions. | +| Cloudflare Worker availability | Very Low | Cloudflare's edge network is highly available; free tier is generous for MVP | +| Relay latency vs P2P | Low | Cloudflare PoPs are globally distributed; relay adds ~5-20ms; negligible for 15Hz position updates | +| Transform interpolation jitter | Low | Linear interpolation at 60fps between 15Hz network updates; add dead reckoning (extrapolate along direction * speed) if needed | +| Sequence number redundancy | None | WebSocket (TCP) guarantees ordering, but sequence numbers provide app-level deduplication on reconnect and useful diagnostic logging. Keep. | + +--- + +## Verification Plan + +1. **Spawn test**: Call `CreateCharacterClone()` + `Add(*roi)` manually, verify a second character renders in the ISLE world at a hardcoded position +2. **Animation test**: With a spawned clone, call `ApplyAnimationTransformation()` in a loop advancing time, verify legs/arms move +3. **Transform test**: Record local player transforms over 10 seconds, replay them on a remote ROI with interpolation, verify smooth movement +4. **Network test**: Two Emscripten instances in separate browser tabs, connect to same Cloudflare Worker room, walk around, verify characters appear and move +5. **Animation in motion**: Verify remote player's limbs animate while moving and stop when standing still +6. **ISLE transition test**: Local player enters a building, verify remote players disappear; exit building, verify they reappear +7. **Disconnect test**: Close one tab, verify remote player disappears in other tab within 5 seconds +8. **Performance**: Measure FPS impact with 2-4 remote players (target: <5% FPS loss) +9. **Build regression**: Ensure project compiles identically with extensions disabled + +--- + +## Source Code Verification Status + +All assumptions in this plan have been verified against the source code: + +| Assumption | Status | Evidence | +|-----------|--------|----------| +| `GetActorROI()` returns shared ROI (refcount bump, same object) | Verified | `legocharactermanager.cpp:250-296` - `character->AddRef()` then `return character->m_roi` | +| `CreateActorROI()` body construction loop is ~100 lines and self-contained | Verified | `legocharactermanager.cpp:459-603` - builds root ROI, 10 child ROIs from `g_actorLODs`, applies textures/colors | +| `OrientableROI::SetLocal2WorldWithWorldDataUpdate(const Matrix4&)` exists | Verified | `orientableroi.h:35` - virtual, vtable+0x20 | +| `LegoROI::ApplyAnimationTransformation()` is static | Verified | `legoroi.h:43` - `static void ApplyAnimationTransformation(LegoTreeNode*, Matrix4&, LegoTime, LegoROI**)` | +| `LegoLocomotionAnimPresenter::GetAnimation()` returns `LegoAnim*` | Verified | `legoanimpresenter.h:109` - `LegoAnim* GetAnimation() { return m_anim; }` (inherited from `LegoAnimPresenter`) | +| Body part names in `g_actorLODs` match animation node names | Verified | `legoactors.cpp:10-93` defines names (body, infohat, head, arm-lft, arm-rt, etc.); `legoanimpresenter.cpp:480` matches via `FindChildROI(name)` with case-insensitive comparison | +| `MxTickleManager::RegisterClient(MxCore*, MxTime)` | Verified | `mxticklemanager.h:47` | +| `LegoOmni::GetUserActor()` returns `LegoPathActor*` | Verified | `legomain.h:166` | +| `LegoOmni::GetCurrentWorld()` returns `LegoWorld*` | Verified | `legomain.h:163` | +| `LegoWorld::GetROIList()` returns `list&` | Verified | `legoworld.h:119` | +| `LegoWorld::Find(const char*, const char*)` returns `MxCore*` | Verified | `legoworld.h:110` | +| `Lego3DManager::Add(ViewROI&)` returns `BOOL` | Verified | `lego3dmanager.h` - inline, delegates to `m_pLego3DView->Add(rROI)`. Also `Remove(ViewROI&)`, `Moved(ViewROI&)` | +| Extensions system uses `Extension::Call()` template pattern | Verified | `extensions/include/extensions/extensions.h` - returns `std::optional`, no-op when disabled or `EXTENSIONS` undefined | +| Extensions registered in `availableExtensions[]` array, enabled via `Enable(key, options)` | Verified | `extensions.h` and `extensions.cpp` | +| CMake: extensions conditionally compiled under `ISLE_EXTENSIONS` | Verified | `CMakeLists.txt:527-535` - `target_compile_definitions(lego1 PUBLIC EXTENSIONS)` | +| Walking animation presenter names come from `g_cycles[11][17]` | **Corrected** | `legoanimationmanager.cpp:59` - names like `"CNs001xx"` (generic), `"CNs001Pe"` (Pepper); **NOT** `"bdwk00"` as originally stated. Plan updated. | +| NPC walk animations loaded at multiple speed tiers | Verified | `legoanimationmanager.cpp:2382-2430` - mood 0 at speed 0.7, mood+4 at speed 4.0, mood+7 for third tier | +| Animation time: `m_actorTime += deltaTime * m_worldSpeed` | Verified | `legopathactor.cpp:359` | +| Cyclic time: `timeInCycle = m_actorTime - duration * ((MxS32)(m_actorTime / duration))` | Verified | `legoanimactor.cpp:61` | +| ROIs survive world transitions via SetVisibility toggle | **Corrected** | `LegoWorld::Enable(FALSE)` calls `ViewManager::RemoveAll(NULL)` which removes ALL ROIs from scene graph (not just hides them). `Enable(TRUE)` only re-adds entity ROIs from `m_entityList`. Remote player ROIs must be explicitly re-added via `Get3DManager()->Add(*roi)`. Solution: `Extension::Call(HandleWorldEnable)` hook in `LegoWorld::Enable()`, same pattern as SiLoader hooks. Plan updated. | +| ISLE world is not destroyed during normal gameplay | Verified | `DeleteWorld`/`RemoveWorld` only called during `Close()` (app shutdown) or for specific worlds (Act2/3, garage). ISLE world uses `Enable(FALSE/TRUE)` pattern. | +| `ViewManager::RemoveAll` does not delete ROI objects | Verified | `viewmanager.cpp:127-152` - only removes from scene graph (`RemoveROIDetailFromScene`), sets LOD level to unset. Does not call `delete`. | +| Vehicle animation is in `g_cycles[type][10]` | Verified | `legoanimationmanager.cpp:2388-2394` - `cycles[10]` checked when `m_vehicleId >= 0`, loaded via `CreateROIAndBuildMap(p_actor, 1.7f)` | +| Vehicle animation tree includes vehicle model | Verified | `legoanimpresenter.cpp:301-331` - `CreateSceneROIs()` iterates animation actors, finds/creates vehicle ROI via `FindROI()` or `CreateAutoROI()`. `BuildROIMap()` then maps both character body parts AND vehicle ROI to animation nodes. | +| Vehicle ROIs are shared world objects | Verified | `legoanimationmanager.cpp:455,2267` - `FindROI(g_vehicles[vehicleId].m_name)` searches scene for pre-existing ROIs like "board", "bikebd", etc. Only one instance per vehicle name exists. | +| `UserActor()` changes to vehicle on enter | Verified | `islepathactor.cpp:94` - `SetUserActor(this)` makes vehicle the UserActor; line 114 restores `m_previousActor` on exit | +| `g_vehicles[7]` vehicle list | Verified | `legoanimationmanager.cpp:48-56` - bikebd, bikepg, bikerd, bikesy, motoni, motola, board | +| Character-vehicle mapping via `m_vehicleId` | Verified | `legoanimationmanager.cpp:251-299` - pepper→board(6), nick→motoni(4), laura→motola(5), etc. | +| Character binding is dynamic in ride animations | Verified | `legoanimpresenter.cpp:1442` - `variableTable->SetVariable(key, p_actor->GetROI()->GetName())` binds any character ROI to animation tree via variable table. Character is swappable; vehicle ROI name is hardcoded in animation data. | +| Any character can ride any small vehicle | Verified | Animation system resolves character dynamically; bike ride anim (CNs001Bd), skate ride anim (CNs001sk), moto ride anim (CNs011Ni) all accept any character ROI. Verified via `CreateROIAndBuildMap` + variable table mechanism. | +| Player vehicle entity ROI names | Verified | `g_sharedModelsHigh`: bike, moto (`legoroi.cpp:47`); `g_alwaysLoadNames`: rcuser, jsuser, dunebugy (`legoroi.cpp:53`); towtk (`isle.cpp:860`); ambul (`legoact2.cpp:287`). Helicopter and SkateBoard entity ROI names come from SI data. | +| `CreateAutoROI` creates single-mesh ROIs from LOD lists | Verified | `legocharactermanager.cpp:987-1044` - looks up LOD list by name, creates single `LegoROI`, adds to 3D scene. Suitable for vehicle cloning, NOT for multi-part character cloning. | +| NPC vehicle variant ROIs created by `CreateAutoROI` | Verified | `legoanimpresenter.cpp:326` - `CreateSceneROIs()` calls `CreateAutoROI(actorName, lodName, FALSE)` to create vehicle variant ROIs for ride animations. | +| No ride animations exist for large vehicles | Verified | `g_cycles[11][17]` index [10] entries reference only bike/skateboard/motorcycle variants. No animation presenters for helicopter, jetski, racecar, dunebuggy, towtrack, or ambulance ride poses. Players see first-person dashboard only. | +| `IslePathActor::Enter()` hides vehicle ROI | Verified | `islepathactor.cpp:77` - `m_roi->SetVisibility(FALSE)` when player enters vehicle. Vehicle entity ROI is available for reuse/cloning while local player drives it. | +| RaceCar leaves ISLE world | Verified | `racecar.cpp:41-51` - HandleClick does NOT call Enter(), sets e_carrace; `legogamestate.cpp:953` - SwitchArea(e_carrace) opens CarRace_World via InvokeAction(e_opendisk). | +| Jetski stays in ISLE world | Verified | `jetski.cpp:122` - HandleClick calls Enter(); `legogamestate.cpp:906` - SwitchArea(e_jetski) calls LoadIsle() (stays in ISLE). Free riding is in ISLE waters; e_jetrace is a separate race event. | +| Ambulance stays in ISLE world | Verified | `ambulance.cpp:389` - HandleClick calls Enter(); mission runs within ISLE world; state handled by Act1State::e_ambulance within Isle notification handler. | +| All other vehicles stay in ISLE | Verified | Bike, SkateBoard, Motocycle, DuneBuggy, Helicopter, TowTrack all call Enter() in HandleClick and stay in ISLE world. | +| Actor ID enum range is 0-6 (none=0, pepper=1..brickster=6) | **Corrected** | `legoactor.h:15-23` - enum: c_none=0, c_pepper=1, c_mama=2, c_papa=3, c_nick=4, c_laura=5, c_brickster=6. Plan originally stated pepper=0..brickster=5. | +| `LegoEntity::GetWorldSpeed()` returns `MxFloat` | Verified | `legoentity.h:91` - `MxFloat GetWorldSpeed() { return m_worldSpeed; }` | +| `LegoEntity::SetWorldTransform(location, direction, up)` takes 3 Vector3 params | Verified | `legoentity.h:60-64` - `virtual void SetWorldTransform(const Vector3& p_location, const Vector3& p_direction, const Vector3& p_up)` | +| `Extension::Call(HandleWorld, p_world)` reference pattern | Verified | `legomain.cpp:417` in `LegoOmni::AddWorld()` | +| Ride animation presenters exist in ISLE world from streaming data | Verified | `legoanimationmanager.cpp:2389-2394` looks up via `world->Find("LegoAnimPresenter", vehicleWC)` - presenters are loaded from SI data into `m_animPresenters`. `g_vehicles[].m_unk0x04` controls dynamic NPC assignment, not presenter existence. | +| `LegoAnim::GetDuration()` and `LegoTree::GetRoot()` | Verified | `legoanim.h:342` - `LegoTime GetDuration() { return m_duration; }`; `legotree.h:79` - `LegoTreeNode* GetRoot() { return m_root; }` (LegoAnim inherits from LegoTree) | +| `LegoTreeNode::GetNumChildren()` and `GetChild()` | Verified | `legotree.h:45` - `LegoU32 GetNumChildren()`; `legotree.h:51` - `LegoTreeNode* GetChild(LegoU32 p_i)` | +| INI extension loading loop auto-discovers new extensions | Verified | `isleapp.cpp:1251-1267` - iterates `availableExtensions[]`, reads section keys via `iniparser_getseckeys()`, builds options map, calls `Extensions::Enable()`. Adding `"extensions:multiplayer"` to `availableExtensions[]` is sufficient. | +| `Extensions::Enable()` sets options + calls Initialize | Verified | `extensions.cpp:8-27` - pattern: `T::options = std::move(p_options); T::enabled = true; T::Initialize();` | +| SiLoader reads INI keys as `"si loader:files"` format | Verified | `siloader.cpp:29` - `options["si loader:files"]`. Section name is extracted from extension key after `:` (`isleapp.cpp:1255`). | +| `UserActor()` can be NULL during world transitions | Verified | `legogamestate.cpp:219` - `SetUserActor(NULL)` before creating new actor. Window exists until line 239 `SetUserActor(newActor)`. `isle.cpp:556,588` - existing code null-checks `UserActor()` before use. | +| Brickster is NPC-only, not playable | Verified | `legovariables.cpp:167-186` - WhoAmIVariable only handles pepper/mama/papa/nick/laura (IDs 1-5, no brickster case). `ambulance.cpp:331-332` - vehicle code clamps actorId to `c_laura` (5). `legoactor.cpp:137` - SetROI loop is `for (i = 1; i <= sizeOfArray - 2; i++)` = 1..5. | +| `Get3DManager()->Add()` only adds parent; children render automatically | Verified | `viewmanager.h:52` - `Add()` pushes parent to `rois` list only. But `ViewManager::Update()` (`viewmanager.cpp:287-308`) calls `ManageVisibilityAndDetailRecursively()` which recursively traverses `GetComp()` children for LOD/geometry. Confirmed by `legocharactermanager.cpp:272` and `legoworld.cpp:708-720` which both add only the parent ROI. One `Add(*roi)` call per remote player is sufficient. | +| `GetWorldSpeed()` is animation playback multiplier | Verified | `legoentity.h:91` - simple getter `return m_worldSpeed`. Set explicitly by code, not automatically by vehicle context. `legopathactor.cpp:359` - used as `m_actorTime += deltaTime * m_worldSpeed` for animation. | +| `LegoCharacterManager` uses case-insensitive name comparison | Verified | `legocharactermanager.h:19-21` - `LegoCharacterComparator` uses `SDL_strcasecmp`. Names like "pepper_mp_1" are safe from colliding with "pepper". | +| Animation presenters survive `LegoWorld::Enable(FALSE/TRUE)` | Verified | `legoworld.cpp:776-791` - disable path toggles `Enable(FALSE)` and stores in `m_disabledObjects`. `legoworld.cpp:723-734` - enable path re-enables from `m_disabledObjects`. Presenters not destroyed. `LegoAnim*` data remains valid. | +| Emscripten interop uses `MAIN_THREAD_EM_ASM` pattern | Verified | `ISLE/emscripten/events.cpp` - `Emscripten_SendEvent()` uses `MAIN_THREAD_EM_ASM`. Also `config.cpp`, `filesystem.cpp`, `haptic.cpp`, `messagebox.cpp`. No `EM_JS` usage found. | +| MxTickleManager uses wall-clock timing (SDL_GetTicks) | Verified | `mxtimer.cpp` - uses `SDL_GetTicks()` for time. `mxticklemanager.cpp:36-63` - compares `(interval + lastTime) < currentTime` with millisecond resolution. 66ms (15Hz) intervals work at 30+ FPS game loop. | diff --git a/extensions/include/extensions/extensions.h b/extensions/include/extensions/extensions.h index 9f02a281..a1fcb95d 100644 --- a/extensions/include/extensions/extensions.h +++ b/extensions/include/extensions/extensions.h @@ -9,7 +9,8 @@ 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 p_options); diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h new file mode 100644 index 00000000..b1bc0065 --- /dev/null +++ b/extensions/include/extensions/multiplayer.h @@ -0,0 +1,44 @@ +#pragma once + +#include "extensions/extensions.h" +#include "mxtypes.h" + +#include +#include + +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 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 diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h new file mode 100644 index 00000000..b17b5a96 --- /dev/null +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -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 +#include +#include +#include + +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> 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 diff --git a/extensions/include/extensions/multiplayer/networktransport.h b/extensions/include/extensions/multiplayer/networktransport.h new file mode 100644 index 00000000..5a9d4e43 --- /dev/null +++ b/extensions/include/extensions/multiplayer/networktransport.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +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 p_callback) = 0; +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h new file mode 100644 index 00000000..7c3ab8ce --- /dev/null +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h new file mode 100644 index 00000000..9f0ad69f --- /dev/null +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -0,0 +1,104 @@ +#pragma once + +#include "extensions/multiplayer/protocol.h" +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include + +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 diff --git a/extensions/include/extensions/multiplayer/websockettransport.h b/extensions/include/extensions/multiplayer/websockettransport.h new file mode 100644 index 00000000..22dc0c0a --- /dev/null +++ b/extensions/include/extensions/multiplayer/websockettransport.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer/networktransport.h" + +#include + +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 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__ diff --git a/extensions/src/extensions.cpp b/extensions/src/extensions.cpp index 2eb276c6..95b57764 100644 --- a/extensions/src/extensions.cpp +++ b/extensions/src/extensions.cpp @@ -1,5 +1,6 @@ #include "extensions/extensions.h" +#include "extensions/multiplayer.h" #include "extensions/siloader.h" #include "extensions/textureloader.h" @@ -19,6 +20,11 @@ void Extensions::Enable(const char* p_key, std::map p_ SiLoader::enabled = true; 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); break; diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp new file mode 100644 index 00000000..da501d82 --- /dev/null +++ b/extensions/src/multiplayer.cpp @@ -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 + +using namespace Extensions; + +std::map 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; +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp new file mode 100644 index 00000000..db648395 --- /dev/null +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -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 +#include +#include +#include + +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 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(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(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(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(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; +} diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp new file mode 100644 index 00000000..76cd7b7c --- /dev/null +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -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 +#include +#include +#include +#include + +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(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(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& 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 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(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; +} diff --git a/extensions/src/multiplayer/server/relay.ts b/extensions/src/multiplayer/server/relay.ts new file mode 100644 index 00000000..32eed0cc --- /dev/null +++ b/extensions/src/multiplayer/server/relay.ts @@ -0,0 +1,111 @@ +export interface Env { + GAME_ROOM: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + 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 = new Map(); + private nextPeerId: number = 1; + + constructor( + private state: DurableObjectState, + private env: Env + ) {} + + async fetch(request: Request): Promise { + 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 }); + } +} diff --git a/extensions/src/multiplayer/websockettransport.cpp b/extensions/src/multiplayer/websockettransport.cpp new file mode 100644 index 00000000..6e4821ae --- /dev/null +++ b/extensions/src/multiplayer/websockettransport.cpp @@ -0,0 +1,199 @@ +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer/websockettransport.h" + +#include +#include +#include + +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 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__