Add multiplayer extension (#789)

* Add multiplayer extension

* Fix animation system to work when host is outside ISLE world

- Move TickHostSessions outside m_inIsleWorld gate so the host can
  coordinate animations from any world
- Load animation catalog early in HandleCreate so the host can
  coordinate before entering the ISLE world
- Use network-reported positions for remote player location detection
  instead of requiring spawned ROIs
- Always erase sessions at launch — the host's job ends when the
  animation starts; clients play and complete independently
- Replace BroadcastAnimComplete with locally-driven completion
  callbacks: host generates eventId at launch, clients cache
  completion JSON at start time, fire it when ScenePlayer finishes
- Make StopAnimation only do local cleanup (stop playback, cancel
  own interest, reset coordinator) without destroying the session
  host, so other players' sessions survive world transitions
- Broadcast state=0 in ResetAnimationState for full teardown paths
  (shutdown, reconnect, host migration) so clients aren't left with
  stale session state

* Fix use-after-free crash in ScenePlayer when remote player disconnects mid-animation

When a remote player's ROI is destroyed (disconnect, timeout, or respawn),
notify all active ScenePlayer instances to null out dangling references
before the ROI is freed. The animation engine already handles null ROI map
entries gracefully, so playback continues for remaining participants.

* Fix crash when performer's child ROIs are left dangling in ScenePlayer

NotifyROIDestroyed now walks the parent chain to also invalidate child
ROIs of the destroyed performer (head, limbs, etc.) that were placed
into the roiMap by BuildROIMap. The ancestor walk happens once; all
other fields are cleaned with simple pointer equality.

* Allow spectator to play click animation during scene playback

* Make PTATCAM track spectator ROI instead of camera in ScenePlayer

* Only regenerate emscripten version files when git state changes

Replace add_custom_target(ALL) with add_custom_command(OUTPUT) so the
version script only runs when .git/HEAD or the current branch ref file
changes, instead of on every build.

* Fix ROI name collision causing dangling pointers in NPC locomotion roiMaps

When ScenePlayer created cloned NPC ROIs for cooperative animations, it
renamed them to match the original character name and added them to the
ViewManager. This created a name collision: two ROIs with the same name.
The original game's AppendROIToScene searches by name and stops at the
first match, so if a locomotion BuildROIMap ran while the clone existed,
it could capture pointers to the clone's child ROIs. When the clone was
later destroyed (CleanupProps), those roiMap entries became dangling
pointers, crashing in AnimateWithTransform at roi.h:151 (SetVisibility).

Fix: use the alias mechanism (already supported by AnimUtils::BuildROIMap)
instead of renaming clones. Also unify all ROI name generation behind a
shared counter to prevent character manager key collisions.
This commit is contained in:
foxtacles 2026-04-05 08:13:15 -07:00 committed by GitHub
parent b3f882f340
commit 7b114bbe59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
103 changed files with 12465 additions and 123 deletions

View File

@ -72,3 +72,65 @@ if(DOWNLOAD_DEPENDENCIES)
set_property(TARGET libweaver PROPERTY CXX_STANDARD_REQUIRED ON)
endif()
if(ISLE_USE_LWS)
if(DOWNLOAD_DEPENDENCIES)
include(FetchContent)
# Fetch mbedTLS for TLS/WSS support in libwebsockets
FetchContent_Declare(
mbedtls
URL https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.5/mbedtls-3.6.5.tar.bz2
URL_MD5 bc79602daf85f1cf35a686b53056de58
# Patch cmake_minimum_required to avoid deprecation error with -Werror=dev
PATCH_COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_SOURCE_DIR}/patch_mbedtls_cmake.cmake
)
block()
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE)
set(BUILD_SHARED_LIBS OFF)
FetchContent_MakeAvailable(mbedtls)
# Point lws at the mbedTLS targets so it skips its own find logic
# Must be inside block() to access mbedtls_SOURCE_DIR/mbedtls_BINARY_DIR
set(LWS_MBEDTLS_INCLUDE_DIRS
"${mbedtls_SOURCE_DIR}/include;${mbedtls_BINARY_DIR}/include"
CACHE STRING "" FORCE
)
set(LWS_MBEDTLS_LIBRARIES
"mbedtls;mbedx509;mbedcrypto"
CACHE STRING "" FORCE
)
endblock()
FetchContent_Declare(
libwebsockets
GIT_REPOSITORY "https://github.com/warmcat/libwebsockets.git"
GIT_TAG "v4.5-stable"
EXCLUDE_FROM_ALL
)
block()
set(LWS_WITH_SSL ON CACHE BOOL "" FORCE)
set(LWS_WITH_MBEDTLS ON CACHE BOOL "" FORCE)
set(LWS_WITH_EXPORT_LWSTARGETS OFF CACHE BOOL "" FORCE)
# mbedTLS isn't built yet at configure time, so lws's check_function_exists
# calls fail. Pre-set the results for APIs that exist in mbedTLS 3.x.
set(LWS_HAVE_mbedtls_md_setup 1 CACHE INTERNAL "" FORCE)
set(LWS_HAVE_mbedtls_rsa_complete 1 CACHE INTERNAL "" FORCE)
set(LWS_WITH_SHARED OFF CACHE BOOL "" FORCE)
set(LWS_WITH_STATIC ON CACHE BOOL "" FORCE)
set(LWS_WITHOUT_TESTAPPS ON CACHE BOOL "" FORCE)
set(LWS_WITH_MINIMAL_EXAMPLES OFF CACHE BOOL "" FORCE)
# Disable treating warnings as errors in libwebsockets (GCC/Clang)
set(DISABLE_WERROR ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(libwebsockets)
endblock()
# Disable MSVC /WX (warnings as errors) that lws sets unconditionally
if(TARGET websockets AND MSVC)
target_compile_options(websockets PRIVATE /WX-)
endif()
else()
find_package(Libwebsockets REQUIRED)
endif()
endif()

7
3rdparty/patch_mbedtls_cmake.cmake vendored Normal file
View File

@ -0,0 +1,7 @@
file(READ "CMakeLists.txt" content)
string(REGEX REPLACE
"cmake_minimum_required\\(VERSION [0-9]+\\.[0-9]+[0-9.]*\\)"
"cmake_minimum_required(VERSION 3.10)"
content "${content}"
)
file(WRITE "CMakeLists.txt" "${content}")

View File

@ -0,0 +1,23 @@
cmake_policy(SET CMP0007 NEW)
execute_process(
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY "${SOURCE_DIR}"
OUTPUT_VARIABLE H OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET
)
if(NOT H)
set(H "unknown")
endif()
# version.js only rewrite if hash changed
set(JS_FILE "${OUTPUT_DIR}/version.js")
set(JS_CONTENT "Module[\"wasmVersion\"]=\"${H}\"")
if(EXISTS "${JS_FILE}")
file(READ "${JS_FILE}" OLD)
if("${OLD}" STREQUAL "${JS_CONTENT}")
# sourceMappingURL uses same hash, skip both
return()
endif()
endif()
file(WRITE "${JS_FILE}" "${JS_CONTENT}")
file(WRITE "${OUTPUT_DIR}/sourceMappingURL" "/symbols/${H}/isle.wasm.map")

View File

@ -13,9 +13,38 @@ if (WINDOWS_STORE)
endif()
if (EMSCRIPTEN)
add_compile_options(-pthread)
add_link_options(-sUSE_WEBGL2=1 -sMIN_WEBGL_VERSION=2 -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=2gb -sUSE_PTHREADS=1 -sPROXY_TO_PTHREAD=1 -sOFFSCREENCANVAS_SUPPORT=1 -sPTHREAD_POOL_SIZE_STRICT=0 -sFORCE_FILESYSTEM=1 -sWASMFS=1 -sEXIT_RUNTIME=1)
add_compile_options(-pthread -gsource-map)
add_link_options(-sUSE_WEBGL2=1 -sMIN_WEBGL_VERSION=2 -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=2gb -sUSE_PTHREADS=1 -sPROXY_TO_PTHREAD=1 -sOFFSCREENCANVAS_SUPPORT=1 -sPTHREAD_POOL_SIZE_STRICT=0 -sFORCE_FILESYSTEM=1 -sWASMFS=1 -sEXIT_RUNTIME=1 -sABORT_ON_WASM_EXCEPTIONS=1 -gsource-map)
set(SDL_PTHREADS ON CACHE BOOL "Enable SDL pthreads" FORCE)
find_program(LLVM_OBJCOPY_BIN NAMES llvm-objcopy HINTS "${EMSCRIPTEN_ROOT_PATH}/../bin" REQUIRED)
set(ISLE_EMSCRIPTEN_VERSION_DIR "${CMAKE_BINARY_DIR}/generated")
# Resolve git HEAD to find file dependencies for change detection.
# .git/HEAD changes on branch switch; the ref file it points to changes on commit.
set(_git_head "${CMAKE_SOURCE_DIR}/.git/HEAD")
set(_git_deps "${_git_head}")
if(EXISTS "${_git_head}")
file(READ "${_git_head}" _head_ref)
string(STRIP "${_head_ref}" _head_ref)
if(_head_ref MATCHES "^ref: (.+)$")
set(_git_ref "${CMAKE_SOURCE_DIR}/.git/${CMAKE_MATCH_1}")
if(EXISTS "${_git_ref}")
list(APPEND _git_deps "${_git_ref}")
endif()
endif()
endif()
add_custom_command(
OUTPUT ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js ${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL
COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${CMAKE_SOURCE_DIR} -DOUTPUT_DIR=${ISLE_EMSCRIPTEN_VERSION_DIR}
-P ${CMAKE_SOURCE_DIR}/CMake/EmscriptenVersion.cmake
DEPENDS ${_git_deps}
COMMENT "Generating emscripten version files"
)
add_custom_target(emscripten_version DEPENDS
${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js
${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL
)
endif()
if (NINTENDO_SWITCH)
@ -56,6 +85,7 @@ option(ISLE_WERROR "Treat warnings as errors" OFF)
cmake_dependent_option(ISLE_USE_DX5 "Build with internal DirectX 5 SDK" "${NOT_MINGW}" "WIN32;CMAKE_SIZEOF_VOID_P EQUAL 4" OFF)
cmake_dependent_option(ISLE_MINIWIN "Use miniwin" ON "NOT ISLE_USE_DX5" OFF)
cmake_dependent_option(ISLE_EXTENSIONS "Use extensions" ON "NOT ISLE_USE_DX5;NOT WINDOWS_STORE" OFF)
cmake_dependent_option(ISLE_USE_LWS "Use libwebsockets for native multiplayer" ON "ISLE_EXTENSIONS;NOT EMSCRIPTEN;NOT NINTENDO_3DS;NOT NINTENDO_SWITCH;NOT VITA" OFF)
cmake_dependent_option(ISLE_BUILD_CONFIG "Build CONFIG.EXE application" ON "MSVC OR ISLE_MINIWIN;NOT NINTENDO_3DS;NOT NINTENDO_SWITCH;NOT WINDOWS_STORE;NOT VITA" OFF)
cmake_dependent_option(ISLE_COMPILE_SHADERS "Compile shaders" ON "SDL_SHADERCROSS_BIN;TARGET Python3::Interpreter" OFF)
cmake_dependent_option(CMAKE_POSITION_INDEPENDENT_CODE "Build with -fPIC" ON "NOT VITA" OFF)
@ -544,10 +574,48 @@ if (ISLE_EXTENSIONS)
# Third person camera extension
extensions/src/thirdpersoncamera.cpp
extensions/src/thirdpersoncamera/controller.cpp
extensions/src/thirdpersoncamera/orbitcamera.cpp
extensions/src/thirdpersoncamera/inputhandler.cpp
extensions/src/thirdpersoncamera/displayactor.cpp
extensions/src/thirdpersoncamera/inputhandler.cpp
extensions/src/thirdpersoncamera/orbitcamera.cpp
# Multiplayer extension
extensions/src/multiplayer.cpp
extensions/src/multiplayer/animation/audioplayer.cpp
extensions/src/multiplayer/animation/catalog.cpp
extensions/src/multiplayer/animation/coordinator.cpp
extensions/src/multiplayer/animation/loader.cpp
extensions/src/multiplayer/animation/locationproximity.cpp
extensions/src/multiplayer/animation/phonemeplayer.cpp
extensions/src/multiplayer/animation/sceneplayer.cpp
extensions/src/multiplayer/animation/sessionhost.cpp
extensions/src/multiplayer/emoteanimhandler.cpp
extensions/src/multiplayer/mputils.cpp
extensions/src/multiplayer/namebubblerenderer.cpp
extensions/src/multiplayer/networkmanager.cpp
extensions/src/multiplayer/protocol.cpp
extensions/src/multiplayer/remoteplayer.cpp
extensions/src/multiplayer/sireader.cpp
extensions/src/multiplayer/worldstatesync.cpp
)
if(EMSCRIPTEN)
target_sources(lego1 PRIVATE
extensions/src/multiplayer/platforms/emscripten/callbacks.cpp
extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp
)
elseif(ISLE_USE_LWS)
target_sources(lego1 PRIVATE
extensions/src/multiplayer/platforms/native/lwstransport.cpp
extensions/src/multiplayer/platforms/native/nativecallbacks.cpp
)
# Skip precompiled headers for native transport files to avoid miniwin/windows.h conflicts
set_source_files_properties(
extensions/src/multiplayer/platforms/native/lwstransport.cpp
extensions/src/multiplayer/platforms/native/nativecallbacks.cpp
PROPERTIES SKIP_PRECOMPILE_HEADERS ON
)
target_compile_definitions(lego1 PRIVATE ISLE_USE_LWS)
target_link_libraries(lego1 PRIVATE websockets)
endif()
endif()
if (ISLE_BUILD_APP)
@ -603,7 +671,21 @@ if (ISLE_BUILD_APP)
ISLE/emscripten/messagebox.cpp
ISLE/emscripten/window.cpp
)
if(ISLE_EXTENSIONS)
target_sources(isle PRIVATE
extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp
)
endif()
target_compile_definitions(isle PRIVATE "ISLE_EMSCRIPTEN_HOST=\"${ISLE_EMSCRIPTEN_HOST}\"")
add_dependencies(isle emscripten_version)
target_link_options(isle PRIVATE --post-js ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js)
set_property(TARGET isle PROPERTY LINK_DEPENDS ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js)
add_custom_command(TARGET isle POST_BUILD
COMMAND ${LLVM_OBJCOPY_BIN} --remove-section=sourceMappingURL
--add-section=sourceMappingURL=${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL
$<TARGET_FILE_DIR:isle>/isle.wasm
COMMENT "Patching sourceMappingURL with build-time git hash"
)
set_property(TARGET isle PROPERTY SUFFIX ".html")
endif()
if(NINTENDO_3DS)

View File

@ -44,3 +44,15 @@ void Emscripten_SendExtensionProgress(const char* p_extension, MxU32 p_progress)
Emscripten_SendEvent("extensionProgress", buf);
}
void Emscripten_SendSaveSlotWritten(MxS32 p_slot)
{
char buf[32];
SDL_snprintf(buf, sizeof(buf), "{\"slot\": %d}", p_slot);
Emscripten_SendEvent("saveSlotWritten", buf);
}
void Emscripten_SendSaveStateChanged()
{
Emscripten_SendEvent("saveStateChanged", NULL);
}

View File

@ -5,5 +5,7 @@
void Emscripten_SendPresenterProgress(MxDSAction* p_action, MxPresenter::TickleState p_tickleState);
void Emscripten_SendExtensionProgress(const char* p_extension, MxU32 p_progress);
void Emscripten_SendSaveSlotWritten(MxS32 p_slot);
void Emscripten_SendSaveStateChanged();
#endif // EMSCRIPTEN_EVENTS_H

View File

@ -37,6 +37,7 @@
#include "viewmanager/viewmanager.h"
#include <array>
#include <extensions/multiplayer.h>
#include <extensions/thirdpersoncamera.h>
#include <miniwin/miniwindevice.h>
#include <type_traits>
@ -872,6 +873,14 @@ SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
case e_badEnding:
rumble(1.0f, 1.0f, 1.0f, 3000);
break;
#ifdef __EMSCRIPTEN__
case e_saveSlotWritten:
Emscripten_SendSaveSlotWritten((MxS32) (intptr_t) event->user.data1);
break;
case e_saveStateChanged:
Emscripten_SendSaveStateChanged();
break;
#endif
}
}
@ -1297,6 +1306,14 @@ inline bool IsleApp::Tick()
if (!Lego()) {
return true;
}
#ifdef EXTENSIONS
if (Extensions::IsMultiplayerRejected()) {
g_closed = TRUE;
return true;
}
#endif
if (!TickleManager()) {
return true;
}

View File

@ -2,6 +2,7 @@
#define ISLE_H
#include "actionsfwd.h"
#include "extensions/fwd.h"
#include "legogamestate.h"
#include "legonamedplane.h"
#include "legostate.h"
@ -164,6 +165,7 @@ class Isle : public LegoWorld {
void SwitchToInfocenter();
friend class Act1State;
friend class Multiplayer::WorldStateSync;
// SYNTHETIC: LEGO1 0x10030a30
// Isle::`scalar deleting destructor'

View File

@ -2,6 +2,7 @@
#define LEGOANIMATIONMANAGER_H
#include "decomp.h"
#include "extensions/fwd.h"
#include "lego1_export.h"
#include "legolocations.h"
#include "legomain.h"
@ -203,6 +204,9 @@ class LegoAnimationManager : public MxCore {
// LegoAnimationManager::`scalar deleting destructor'
private:
friend class Multiplayer::NetworkManager;
friend class Multiplayer::Animation::Catalog;
void Init();
MxResult FUN_100605e0(
MxU32 p_index,

View File

@ -2,6 +2,7 @@
#define LEGOBUILDINGMANAGER_H
#include "decomp.h"
#include "extensions/fwd.h"
#include "lego1_export.h"
#include "misc/legotypes.h"
#include "mxcore.h"
@ -98,6 +99,8 @@ class LegoBuildingManager : public MxCore {
// LegoBuildingManager::`scalar deleting destructor'
private:
friend class Multiplayer::WorldStateSync;
static char* g_customizeAnimFile;
static MxS32 g_maxMove[16];
static MxU32 g_maxSound;

View File

@ -2,6 +2,7 @@
#define LEGOCACHSOUND_H
#include "decomp.h"
#include "extensions/fwd.h"
#include "lego3dsound.h"
#include "mxcore.h"
#include "mxstring.h"
@ -58,6 +59,9 @@ class LegoCacheSound : public MxCore {
// LegoCacheSound::`scalar deleting destructor'
private:
friend class Multiplayer::Animation::AudioPlayer;
friend class Multiplayer::NetworkManager;
void Init();
void CopyData(MxU8* p_data, MxU32 p_dataSize);
MxString GetBaseFilename(MxString& p_path);

View File

@ -180,6 +180,7 @@ class LegoInputManager : public MxPresenter {
// LegoInputManager::`scalar deleting destructor'
private:
friend class Extensions::MultiplayerExt;
friend class Extensions::ThirdPersonCameraExt;
void InitializeHaptics();

View File

@ -1,6 +1,7 @@
#ifndef LEGOMODELPRESENTER_H
#define LEGOMODELPRESENTER_H
#include "extensions/fwd.h"
#include "lego1_export.h"
#include "mxvideopresenter.h"
@ -62,6 +63,8 @@ class LegoModelPresenter : public MxVideoPresenter {
void Destroy(MxBool p_fromDestructor);
private:
friend class Multiplayer::Animation::Catalog;
LegoROI* m_roi; // 0x64
MxBool m_addedToView; // 0x68

View File

@ -2,6 +2,7 @@
#define LEGOPLANTMANAGER_H
#include "decomp.h"
#include "extensions/fwd.h"
#include "legomain.h"
#include "mxcore.h"
@ -67,6 +68,8 @@ class LegoPlantManager : public MxCore {
// LegoPlantManager::`scalar deleting destructor'
private:
friend class Multiplayer::WorldStateSync;
void RemovePlant(MxS32 p_index, LegoOmni::World p_worldId);
void AdjustHeight(MxS32 p_index);
LegoPlantInfo* GetInfo(LegoEntity* p_entity);

View File

@ -37,7 +37,9 @@ enum GameEvent {
e_skeletonKick,
e_raceFinished,
e_badEnding,
e_goodEnding
e_goodEnding,
e_saveSlotWritten,
e_saveStateChanged
};
class BoundingSphere;
@ -84,7 +86,7 @@ LegoNamedTexture* ReadNamedTexture(LegoStorage* p_storage);
void WriteDefaultTexture(LegoStorage* p_storage, const char* p_name);
void WriteNamedTexture(LegoStorage* p_storage, LegoNamedTexture* p_namedTexture);
void LoadFromNamedTexture(LegoNamedTexture* p_namedTexture);
void EmitGameEvent(GameEvent p_event);
void EmitGameEvent(GameEvent p_event, void* p_data = NULL);
// FUNCTION: BETA10 0x100260a0
inline void StartIsleAction(IsleScript::Script p_objectId)

View File

@ -1,6 +1,7 @@
#include "ambulance.h"
#include "decomp.h"
#include "extensions/multiplayer.h"
#include "isle.h"
#include "isle_actions.h"
#include "jukebox_actions.h"
@ -26,6 +27,8 @@
#include <SDL3/SDL_stdinc.h>
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(Ambulance, 0x184)
DECOMP_SIZE_ASSERT(AmbulanceMissionState, 0x24)
@ -458,6 +461,7 @@ MxLong Ambulance::HandleControl(LegoControlManagerNotificationParam& p_param)
MxSoundPresenter* presenter =
(MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "AmbulanceHorn_Sound");
presenter->Enable(p_param.m_enabledChild);
Extension<MultiplayerExt>::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId);
break;
}
}

View File

@ -1,5 +1,6 @@
#include "bike.h"
#include "extensions/multiplayer.h"
#include "isle.h"
#include "isle_actions.h"
#include "jukebox_actions.h"
@ -13,6 +14,8 @@
#include "mxtransitionmanager.h"
#include "scripts.h"
using namespace Extensions;
DECOMP_SIZE_ASSERT(Bike, 0x164)
// FUNCTION: LEGO1 0x10076670
@ -98,6 +101,7 @@ MxLong Bike::HandleControl(LegoControlManagerNotificationParam& p_param)
MxSoundPresenter* presenter =
(MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "BikeHorn_Sound");
presenter->Enable(p_param.m_enabledChild);
Extension<MultiplayerExt>::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId);
break;
}
}

View File

@ -1,6 +1,7 @@
#include "dunebuggy.h"
#include "decomp.h"
#include "extensions/multiplayer.h"
#include "isle.h"
#include "isle_actions.h"
#include "jukebox_actions.h"
@ -21,6 +22,8 @@
#include <SDL3/SDL_stdinc.h>
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(DuneBuggy, 0x16c)
// GLOBAL: LEGO1 0x100f7660
@ -141,6 +144,7 @@ MxLong DuneBuggy::HandleControl(LegoControlManagerNotificationParam& p_param)
MxSoundPresenter* presenter =
(MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "DuneCarHorn_Sound");
presenter->Enable(p_param.m_enabledChild);
Extension<MultiplayerExt>::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId);
break;
}
}

View File

@ -1,11 +1,14 @@
#include "isleactor.h"
#include "extensions/multiplayer.h"
#include "legoentity.h"
#include "legoworld.h"
#include "misc.h"
#include "mxnotificationparam.h"
#include "scripts.h"
using namespace Extensions;
DECOMP_SIZE_ASSERT(IsleActor, 0x7c)
// FUNCTION: LEGO1 0x1002c780
@ -45,7 +48,12 @@ MxLong IsleActor::Notify(MxParam& p_param)
result = HandleButtonDown((LegoControlManagerNotificationParam&) p_param);
break;
case c_notificationClick:
if (Extension<MultiplayerExt>::Call(MP::HandleEntityNotify, (LegoEntity*) this).value_or(FALSE)) {
result = 1;
}
else {
result = HandleClick();
}
break;
case c_notificationEndAnim:
result = HandleEndAnim();

View File

@ -1,5 +1,6 @@
#include "towtrack.h"
#include "extensions/multiplayer.h"
#include "isle.h"
#include "isle_actions.h"
#include "jukebox_actions.h"
@ -22,6 +23,8 @@
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(TowTrack, 0x180)
DECOMP_SIZE_ASSERT(TowTrackMissionState, 0x28)
@ -502,6 +505,7 @@ MxLong TowTrack::HandleControl(LegoControlManagerNotificationParam& p_param)
case IsleScript::c_TowHorn_Ctl:
MxSoundPresenter* presenter = (MxSoundPresenter*) CurrentWorld()->Find("MxSoundPresenter", "TowHorn_Sound");
presenter->Enable(p_param.m_enabledChild);
Extension<MultiplayerExt>::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId);
break;
}
}

View File

@ -1,6 +1,7 @@
#include "legocharactermanager.h"
#include "3dmanager/lego3dmanager.h"
#include "extensions/multiplayer.h"
#include "extensions/thirdpersoncamera.h"
#include "legoactors.h"
#include "legoanimactor.h"
@ -283,7 +284,8 @@ LegoROI* LegoCharacterManager::GetActorROI(const char* p_name, MxBool p_createEn
if (character != NULL) {
if (p_createEntity && character->m_roi->GetEntity() == NULL &&
!Extension<ThirdPersonCameraExt>::Call(TP::IsClonedCharacter, p_name).value_or(FALSE)) {
!Extension<ThirdPersonCameraExt>::Call(TP::IsClonedCharacter, p_name).value_or(FALSE) &&
!Extension<MultiplayerExt>::Call(MP::IsClonedCharacter, p_name).value_or(FALSE)) {
LegoExtraActor* actor = new LegoExtraActor();
actor->SetROI(character->m_roi, FALSE, FALSE);

View File

@ -12,6 +12,7 @@
#include "dunebuggy.h"
#include "dunecar_actions.h"
#include "elevbott_actions.h"
#include "extensions/multiplayer.h"
#include "garage_actions.h"
#include "helicopter.h"
#include "histbook_actions.h"
@ -64,6 +65,8 @@
#include <assert.h>
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(LegoGameState::Username, 0x0e)
DECOMP_SIZE_ASSERT(LegoGameState::ScoreItem, 0x2c)
DECOMP_SIZE_ASSERT(LegoGameState::History, 0x374)
@ -333,6 +336,8 @@ MxResult LegoGameState::Save(MxULong p_slot)
SerializeScoreHistory(LegoFile::c_write);
m_isDirty = FALSE;
EmitGameEvent(e_saveSlotWritten, (void*) (intptr_t) p_slot);
done:
return result;
}
@ -364,6 +369,8 @@ MxResult LegoGameState::DeleteState()
// FUNCTION: BETA10 0x10084329
MxResult LegoGameState::Load(MxULong p_slot)
{
Extension<MultiplayerExt>::Call(MP::HandleBeforeSaveLoad);
MxResult result = FAILURE;
LegoFile storage;
MxVariableTable* variableTable = VariableTable();
@ -456,6 +463,8 @@ MxResult LegoGameState::Load(MxULong p_slot)
result = SUCCESS;
m_isDirty = FALSE;
Extension<MultiplayerExt>::Call(MP::HandleSaveLoaded);
done:
if (result != SUCCESS) {
OmniError("Game state loading was not successful!", 0);

View File

@ -807,10 +807,11 @@ void LoadFromNamedTexture(LegoNamedTexture* p_namedTexture)
}
}
void EmitGameEvent(GameEvent p_event)
void EmitGameEvent(GameEvent p_event, void* p_data)
{
SDL_Event event;
SDL_Event event = {};
event.user.type = g_legoSdlEvents.m_gameEvent;
event.user.code = p_event;
event.user.data1 = p_data;
SDL_PushEvent(&event);
}

View File

@ -2,6 +2,7 @@
#include "3dmanager/lego3dmanager.h"
#include "define.h"
#include "extensions/multiplayer.h"
#include "legoanimationmanager.h"
#include "legobuildingmanager.h"
#include "legocameracontroller.h"
@ -20,6 +21,8 @@
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(LegoEntity, 0x68)
// FUNCTION: LEGO1 0x100105f0
@ -486,6 +489,10 @@ MxLong LegoEntity::Notify(MxParam& p_param)
InvokeAction(m_actionType, MxAtomId(m_siFile, e_lowerCase2), m_targetEntityId, this);
}
else {
if (Extension<MultiplayerExt>::Call(MP::HandleEntityNotify, this).value_or(FALSE)) {
return 1;
}
switch (GameState()->GetActorId()) {
case LegoActor::c_pepper:
if (GameState()->GetCurrentAct() != LegoGameState::e_act2 &&

View File

@ -1,6 +1,7 @@
#include "legoworld.h"
#include "anim/legoanim.h"
#include "extensions/multiplayer.h"
#include "extensions/siloader.h"
#include "extensions/thirdpersoncamera.h"
#include "legoanimationmanager.h"
@ -756,6 +757,7 @@ void LegoWorld::Enable(MxBool p_enable)
#endif
Extension<ThirdPersonCameraExt>::Call(TP::HandleWorldEnable, this, TRUE);
Extension<MultiplayerExt>::Call(MP::HandleWorldEnable, this, TRUE);
}
else if (!p_enable && m_disabledObjects.size() == 0) {
MxPresenter* presenter;
@ -820,6 +822,7 @@ void LegoWorld::Enable(MxBool p_enable)
GetViewManager()->RemoveAll(NULL);
Extension<ThirdPersonCameraExt>::Call(TP::HandleWorldEnable, this, FALSE);
Extension<MultiplayerExt>::Call(MP::HandleWorldEnable, this, FALSE);
}
}

View File

@ -1,5 +1,6 @@
#include "legoinputmanager.h"
#include "extensions/multiplayer.h"
#include "extensions/thirdpersoncamera.h"
#include "legocameracontroller.h"
#include "legocontrolmanager.h"
@ -402,7 +403,8 @@ MxBool LegoInputManager::ProcessOneEvent(LegoEventNotificationParam& p_param)
if (entity && entity->Notify(p_param) != 0) {
return TRUE;
}
if (Extension<ThirdPersonCameraExt>::Call(TP::HandleROIClick, roi, p_param).value_or(FALSE)) {
if (Extension<MultiplayerExt>::Call(MP::HandleROIClick, roi, p_param).value_or(FALSE) ||
Extension<ThirdPersonCameraExt>::Call(TP::HandleROIClick, roi, p_param).value_or(FALSE)) {
return TRUE;
}
}

View File

@ -1,6 +1,7 @@
#include "legomain.h"
#include "3dmanager/lego3dmanager.h"
#include "extensions/multiplayer.h"
#include "extensions/siloader.h"
#include "extensions/thirdpersoncamera.h"
#include "islepathactor.h"
@ -357,6 +358,7 @@ MxResult LegoOmni::Create(MxOmniCreateParam& p_param)
#endif
Extension<ThirdPersonCameraExt>::Call(TP::HandleCreate);
Extension<MultiplayerExt>::Call(MP::HandleCreate);
result = SUCCESS;
}
else {

View File

@ -5,6 +5,7 @@
#include "bike.h"
#include "carrace.h"
#include "dunebuggy.h"
#include "extensions/multiplayer.h"
#include "extensions/siloader.h"
#include "helicopter.h"
#include "isle_actions.h"
@ -296,6 +297,11 @@ void Isle::ReadyWorld()
MxLong Isle::HandleControl(LegoControlManagerNotificationParam& p_param)
{
if (p_param.m_enabledChild == 1) {
if (Extension<MultiplayerExt>::Call(MP::HandleSkyLightControl, (MxU32) p_param.m_clickedObjectId)
.value_or(FALSE)) {
return 1;
}
MxDSAction action;
switch (p_param.m_clickedObjectId) {

View File

@ -356,6 +356,8 @@ void RegistrationBook::LoadSave(MxS16 p_checkMarkIndex)
break;
}
EmitGameEvent(e_saveStateChanged);
m_infocenterState->m_state = InfocenterState::e_selectedSave;
if (m_vehiclesToPosition == 0 && !m_awaitLoad) {
DeleteObjects(&m_atomId, RegbookScript::c_iic006in_RunAnim, RegbookScript::c_iic008in_PlayWav);

14
docker/relay/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:22-slim
WORKDIR /app
COPY extensions/src/multiplayer/server/package.json ./
RUN npm install
COPY extensions/src/multiplayer/server/*.ts extensions/src/multiplayer/server/wrangler.toml ./
EXPOSE 8787
# Run wrangler directly as PID 1 so it receives SIGINT (Ctrl+C)
# and shuts down gracefully. Using npx as PID 1 swallows signals.
CMD ["node_modules/.bin/wrangler", "dev", "--ip", "0.0.0.0", "--port", "8787"]

View File

@ -0,0 +1,91 @@
# ROI Direction Conventions & Third-Person Camera
## Background: The Two Z-Axis Conventions
The game engine represents an actor's facing direction via the z-axis of its ROI
(Real-time Object Instance) local-to-world transform. Two opposite conventions
exist throughout the codebase:
| Convention | ROI z-axis points... | Used by |
|----------------|--------------------------|--------------------------------------------------------|
| **forward-z** | Toward visual forward | `PlaceActor` (with `m_cameraFlag=TRUE`), cam anim end |
| **backward-z** | Away from visual forward | After `Enter()`'s `TurnAround()`, vehicle ROIs |
Toggling between conventions is done by `IslePathActor::TurnAround` (or the
local `FlipMatrixDirection` helper): negate the z-axis and recompute the right
vector.
## Design Choice: Forward-Z
The third-person orbit camera uses **forward-z**, matching the convention that
`PlaceActor` naturally produces. This eliminates the need to flip the ROI
direction after every `PlaceActor` call.
`ComputeOrbitVectors` treats local Z+ as the character's visual forward and
places the camera at local -Z (behind the character), looking toward +Z.
## Engine Behavior
The engine's actor lifecycle:
```
Enter()
-> ResetWorldTransform(TRUE) sets m_cameraFlag = TRUE
-> TurnAround() flips to backward-z
-> TransformPointOfView() sets 1st-person camera
PlaceActor() resets to forward-z <- what we use
```
`PlaceActor` always produces forward-z (when `m_cameraFlag=TRUE`), which is
exactly what the orbit camera expects. No direction correction is needed after
`PlaceActor` runs.
## World Transition Timing
The one remaining complexity is timing during world transitions. The event order
is:
1. `OnWorldEnabled` fires (from `LegoWorld::Enable`, BEFORE `SpawnPlayer`)
2. `ReinitForCharacter` sets up the display ROI and marks `m_active = true`
3. `Enter()` fires `OnActorEnter` -- ROI is at **stale position** from previous session
4. `PlaceActor` sets ROI to correct spawn position
5. First `Tick` -- `ApplyOrbitCamera` sets the camera at the correct position
Between steps 3 and 5, the ROI position is stale. If we set up the orbit camera
in step 3, the stale view would freeze on screen during the ~500ms world load.
The `m_pendingWorldTransition` flag handles this: set in `OnWorldEnabled`,
it causes `OnActorEnter` and `ReinitForCharacter` to skip camera setup.
Cleared in the first `Tick` after `PlaceActor`, where `ApplyOrbitCamera`
naturally handles the camera. The orbit state (yaw, pitch, distance) is also
reset to defaults in `OnWorldEnabled`.
## Display Clone Direction
The native actor ROI is invisible in 3rd-person mode. A display clone renders
the character model instead. Character meshes face -z, so the clone needs
backward-z to look correct. When syncing the clone's transform from the native
ROI (which is in forward-z), `Tick` negates the z-axis and recomputes the right
vector -- the same operation as `TurnAround`.
This also affects the right vector (X-axis): forward-z and backward-z produce
opposite right vectors. The orbit yaw input is negated to compensate, keeping
drag-right = camera-moves-right.
## Cam Anim Interaction
While a cam anim locks the player (`GetActorState() == c_disabled`), two
things protect the orbit camera:
1. **Tick guard**: `ApplyOrbitCamera` is skipped so it doesn't fight the cam
anim's `TransformPointOfView`. Without this, the cam anim end handler would
read our elevated orbit camera position and place the actor in the air.
2. **`OnCamAnimEnd`**: When the cam anim releases the player (first space bar
interruption or natural end), this callback calls `SetupCamera` to restore
the orbit camera.
After the first interruption, the actor state resets to `c_initial` and the
orbit camera resumes immediately -- even if `m_animRunning` is still true for
background animations playing in the world.

View File

@ -99,7 +99,7 @@ class CharacterAnimator {
void BuildRideAnimation(int8_t p_vehicleType, LegoROI* p_playerROI);
void ClearRideAnimation();
int8_t GetCurrentVehicleType() const { return m_currentVehicleType; }
void SetCurrentVehicleType(int8_t p_vehicleType) { m_currentVehicleType = p_vehicleType; }
void SetCurrentVehicleType(int8_t p_currentVehicleType) { m_currentVehicleType = p_currentVehicleType; }
bool IsInVehicle() const { return m_currentVehicleType != VEHICLE_NONE; }
LegoROI* GetRideVehicleROI() const { return m_ridePropGroup.propCount > 0 ? m_ridePropGroup.propROIs[0] : nullptr; }
LegoAnim* GetRideAnim() const { return m_ridePropGroup.anim; }
@ -123,11 +123,11 @@ class CharacterAnimator {
(m_extraAnimActive && m_config.extraAnimHandler->IsMultiPart(m_currentExtraAnimId)));
}
int8_t GetFrozenExtraAnimId() const { return m_frozenExtraAnimId; }
void SetFrozenExtraAnimId(int8_t p_id, LegoROI* p_roi);
void SetFrozenExtraAnimId(int8_t p_frozenExtraAnimId, LegoROI* p_roi);
// Animation time (needed for vehicle ride tick in ThirdPersonCameraExt)
float GetAnimTime() const { return m_animTime; }
void SetAnimTime(float p_time) { m_animTime = p_time; }
void SetAnimTime(float p_animTime) { m_animTime = p_animTime; }
void ResetAnimState();
static constexpr float ANIM_TIME_SCALE = 2000.0f;
@ -138,7 +138,12 @@ class CharacterAnimator {
using AnimCache = AnimUtils::AnimCache;
AnimCache* GetOrBuildAnimCache(LegoROI* p_roi, const char* p_animName);
void StartExtraAnimPhase(uint8_t p_id, int p_phaseIndex, AnimCache* p_cache, LegoROI* p_roi);
void StartExtraAnimPhase(
uint8_t p_currentExtraAnimId,
int p_phaseIndex,
AnimCache* p_extraAnimCache,
LegoROI* p_roi
);
void ClearFrozenState();
void ClearPropGroup(PropGroup& p_group);
void PlayROISound(const char* p_key, LegoROI* p_roi);

View File

@ -11,7 +11,7 @@
namespace Extensions
{
constexpr const char* availableExtensions[] =
{"extensions:texture loader", "extensions:si loader", "extensions:third person camera"};
{"extensions:texture loader", "extensions:si loader", "extensions:third person camera", "extensions:multiplayer"};
LEGO1_EXPORT void Enable(const char* p_key, std::map<std::string, std::string> p_options);

View File

@ -3,6 +3,7 @@
namespace Extensions
{
class MultiplayerExt;
class ThirdPersonCameraExt;
namespace Common
{
@ -15,4 +16,16 @@ class OrbitCamera;
} // namespace ThirdPersonCamera
} // namespace Extensions
namespace Multiplayer
{
class NetworkManager;
class WorldStateSync;
namespace Animation
{
class AudioPlayer;
class Catalog;
class Controller;
} // namespace Animation
} // namespace Multiplayer
#endif // EXTENSIONS_FWD_H

View File

@ -0,0 +1,92 @@
#pragma once
#include "extensions/extensions.h"
#include "mxtypes.h"
#include <map>
#include <string>
class LegoEntity;
class LegoEventNotificationParam;
class LegoROI;
class LegoWorld;
namespace Multiplayer
{
class NetworkManager;
class NetworkTransport;
class PlatformCallbacks;
} // namespace Multiplayer
namespace Extensions
{
class MultiplayerExt {
public:
static void Initialize();
static void HandleCreate();
static MxBool HandleWorldEnable(LegoWorld* p_world, MxBool p_enable);
// Intercepts click notifications on plants/buildings for multiplayer routing.
// Returns TRUE if the click should be suppressed locally (non-host).
static MxBool HandleEntityNotify(LegoEntity* p_entity);
// Intercepts observatory sky/light controls for multiplayer routing.
// Returns TRUE if the local action should be suppressed (non-host).
static MxBool HandleSkyLightControl(MxU32 p_controlId);
// Handles clicks on entity-less ROIs (remote players, display actor overrides).
// When multiplayer is enabled, all customization goes through the network.
static MxBool HandleROIClick(LegoROI* p_rootROI, LegoEventNotificationParam& p_param);
static std::map<std::string, std::string> options;
static bool enabled;
static void HandleHornPressed(MxU32 p_controlId);
static MxBool IsClonedCharacter(const char* p_name);
static void HandleBeforeSaveLoad();
static void HandleSaveLoaded();
static MxBool CheckRejected();
static Multiplayer::NetworkManager* GetNetworkManager();
private:
static std::string s_relayUrl;
static std::string s_room;
static Multiplayer::NetworkManager* s_networkManager;
static Multiplayer::NetworkTransport* s_transport;
static Multiplayer::PlatformCallbacks* s_callbacks;
};
#ifdef EXTENSIONS
LEGO1_EXPORT bool IsMultiplayerRejected();
#endif
namespace MP
{
#ifdef EXTENSIONS
constexpr auto HandleCreate = &MultiplayerExt::HandleCreate;
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
constexpr auto HandleSkyLightControl = &MultiplayerExt::HandleSkyLightControl;
constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick;
constexpr auto HandleHornPressed = &MultiplayerExt::HandleHornPressed;
constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter;
constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad;
constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded;
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
#else
constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr;
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
constexpr decltype(&MultiplayerExt::HandleSkyLightControl) HandleSkyLightControl = nullptr;
constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr;
constexpr decltype(&MultiplayerExt::HandleHornPressed) HandleHornPressed = nullptr;
constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr;
constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr;
constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr;
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
#endif
} // namespace MP
}; // namespace Extensions

View File

@ -0,0 +1,33 @@
#pragma once
#include "extensions/multiplayer/animation/loader.h"
#include <cstdint>
#include <vector>
class LegoCacheSound;
namespace Multiplayer::Animation
{
class AudioPlayer {
public:
// Create LegoCacheSound objects from SceneAnimData's audio tracks
void Init(const std::vector<SceneAnimData::AudioTrack>& p_tracks);
// Start sounds whose time offset has been reached
void Tick(float p_elapsedMs, const char* p_roiName);
// Stop and delete all sounds
void Cleanup();
private:
struct ActiveSound {
LegoCacheSound* sound;
uint32_t timeOffset;
bool started;
};
std::vector<ActiveSound> m_activeSounds;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,148 @@
#pragma once
#include <cstdint>
#include <map>
#include <vector>
class LegoROI;
struct AnimInfo;
namespace Multiplayer::Animation
{
enum AnimCategory : uint8_t {
e_npcAnim, // has named character performer && location == -1
e_camAnim, // has named character performer && location >= 0
e_otherAnim // no named character performers (ambient/prop-only)
};
// Number of core playable characters (Pepper, Mama, Papa, Nick, Laura) = g_actorInfoInit indices 0-4
static const int8_t CORE_CHARACTER_COUNT = 5;
// Spectator mask with all core characters enabled
static const uint8_t ALL_CORE_ACTORS_MASK = (1 << CORE_CHARACTER_COUNT) - 1;
// Sentinel value for "no animation selected"
static const uint16_t ANIM_INDEX_NONE = 0xFFFF;
// World slot constants for animIndex encoding (top 2 bits)
static const uint8_t WORLD_SLOT_ACT1 = 0;
static const uint8_t WORLD_SLOT_ACT2 = 1;
static const uint8_t WORLD_SLOT_ACT3 = 2;
// Compose a globally unique animIndex from a world slot and a local index within that world's AnimInfo array.
static constexpr uint16_t WorldAnimIndex(uint8_t p_worldSlot, uint16_t p_localIndex)
{
return (uint16_t(p_worldSlot) << 14) | (p_localIndex & 0x3FFF);
}
// Extract the world slot (0-2) from a world-encoded animIndex.
static constexpr uint8_t GetWorldSlot(uint16_t p_animIndex)
{
return p_animIndex >> 14;
}
// Extract the local index (0-16383) from a world-encoded animIndex.
static constexpr uint16_t GetLocalIndex(uint16_t p_animIndex)
{
return p_animIndex & 0x3FFF;
}
// Extract the character indices from a performer bitmask.
std::vector<int8_t> GetPerformerIndices(uint64_t p_performerMask);
struct CatalogEntry {
uint16_t animIndex; // World-encoded index: top 2 bits = world slot, bottom 14 = local index
int8_t worldId; // LegoOmni::World enum value for this animation's source world
AnimCategory category;
uint8_t spectatorMask; // Which core actors can trigger (bit0=Pepper..bit4=Laura)
uint64_t performerMask; // Bitmask of g_actorInfoInit[] indices that appear as character models
int16_t location; // -1 = anywhere, >= 0 = specific location
uint8_t modelCount; // Number of models in animation
uint8_t vehicleMask; // Bitmask of g_vehicles[] indices required (bit0=bikebd..bit6=board)
};
class Catalog {
public:
~Catalog();
// Parse DTA files for all supported worlds and build the catalog.
void Refresh();
const AnimInfo* GetAnimInfo(uint16_t p_animIndex) const;
const CatalogEntry* FindEntry(uint16_t p_animIndex) const;
// All non-otherAnim entries at a location (-1 = NPC anims, >= 0 = location-bound)
std::vector<const CatalogEntry*> GetAnimationsAtLocation(int16_t p_location) const;
// Check if a player can fill any role (spectator or participant) in this animation.
// Accepts a display actor index (converted to g_actorInfoInit index internally).
bool CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActorIndex) const;
// Same check but using a g_actorInfoInit index directly.
static bool CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex);
// Check if a set of character indices can collectively trigger this animation.
// p_onVehicle: parallel array indicating if each player is riding their vehicle (nullable).
// p_filledPerformers: bitmask of which performer bits in performerMask are covered.
// p_spectatorFilled: whether a valid spectator was found among unassigned players.
bool CanTrigger(
const CatalogEntry* p_entry,
const int8_t* p_charIndices,
const uint8_t* p_onVehicle,
uint8_t p_count,
uint64_t* p_filledPerformers,
bool* p_spectatorFilled
) const;
// Check if the spectator mask allows this character to spectate.
// Does NOT check performer exclusion — caller must do that if needed.
static bool CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex);
// Vehicle riding state for eligibility checks.
enum VehicleState : uint8_t {
e_onFoot = 0, // Not riding anything
e_onOwnVehicle = 1, // Riding character's own vehicle (e.g. Pepper on skateboard)
e_onOtherVehicle = 2 // Riding a vehicle that isn't the character's own
};
// Check if a player's vehicle state is compatible with the animation's vehicle requirements.
static bool CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState);
// Determine the vehicle state for a character given their current ride vehicle ROI.
static VehicleState GetVehicleState(int8_t p_charIndex, LegoROI* p_vehicleROI);
// Classify a g_vehicles[] index into a vehicle category.
// Returns 0=bike, 1=motorcycle, 2=skateboard, -1=invalid.
static int8_t GetVehicleCategory(int8_t p_vehicleIdx);
// Convert a display actor index to the g_actorInfoInit[] index used by animations.
// Returns -1 if no match.
static int8_t DisplayActorToCharacterIndex(uint8_t p_displayActorIndex);
// Map a LegoOmni::World enum value to a world slot index (0-2).
// Returns 0xFF if the world is not supported.
static uint8_t WorldIdToSlot(int8_t p_worldId);
private:
struct WorldAnimData {
int8_t worldId;
uint8_t worldSlot;
AnimInfo* anims;
uint16_t animCount;
};
bool ParseDTAFile(int8_t p_worldId, AnimInfo*& p_outAnims, uint16_t& p_outCount);
void BuildEntries(const WorldAnimData& p_world);
void LoadWorldParts();
void Cleanup();
static void FreeAnimInfo(AnimInfo* p_anims, uint16_t p_count);
std::vector<CatalogEntry> m_entries;
std::map<int16_t, std::vector<size_t>> m_locationIndex; // location ID → indices into m_entries
std::vector<WorldAnimData> m_worldData;
std::vector<LegoROI*> m_modelROIs; // keep model ROIs alive to preserve LOD refcounts
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,110 @@
#pragma once
#include <cstdint>
#include <map>
#include <vector>
namespace Multiplayer::Animation
{
class Catalog;
struct CatalogEntry;
enum class CoordinationState : uint8_t {
e_idle,
e_interested,
e_countdown,
e_playing
};
struct SlotInfo {
// Character names that can fill this slot.
// Performer slots: always 1 name (the specific character).
// Spectator slot: ["any"] if ALL_CORE_ACTORS_MASK, otherwise the specific allowed names.
std::vector<const char*> names;
bool filled;
};
struct EligibilityInfo {
uint16_t animIndex;
bool eligible; // All requirements met: at location and all roles filled
bool atLocation; // At the right location (or location == -1)
const CatalogEntry* entry; // Pointer into catalog (valid until next Refresh)
std::vector<SlotInfo> slots; // All role slots (performers + spectator), filled status each
};
struct SessionView {
CoordinationState state;
uint16_t countdownMs;
uint32_t countdownEndTime; // SDL_GetTicks() timestamp when countdown expires (client-side)
uint32_t peerSlots[8]; // peerId per slot (matches AnimUpdateMsg layout)
uint8_t slotCount;
};
class Coordinator {
public:
Coordinator();
void SetCatalog(const Catalog* p_catalog);
CoordinationState GetState() const { return m_state; }
uint16_t GetCurrentAnimIndex() const { return m_currentAnimIndex; }
void SetLocalPeerId(uint32_t p_localPeerId);
void SetInterest(uint16_t p_currentAnimIndex);
void ClearInterest();
// Compute eligibility for animations at a location.
// p_locationChars: local player + remote players at the same location (for cam anims).
// p_proximityChars: local player + remote players within proximity (for NPC anims).
// p_locationVehicles/p_proximityVehicles: parallel arrays of VehicleState values.
std::vector<EligibilityInfo> ComputeEligibility(
int16_t p_location,
const int8_t* p_locationChars,
const uint8_t* p_locationVehicles,
uint8_t p_locationCount,
const int8_t* p_proximityChars,
const uint8_t* p_proximityVehicles,
uint8_t p_proximityCount
) const;
// Auto-clear interest if current animation is not available at any of the new locations.
void OnLocationChanged(const std::vector<int16_t>& p_locations, const Catalog* p_catalog);
void Reset();
void ResetLocalState();
void RemoveSession(uint16_t p_animIndex);
// Apply authoritative session state from host
void ApplySessionUpdate(
uint16_t p_currentAnimIndex,
uint8_t p_state,
uint16_t p_countdownMs,
const uint32_t p_slots[8],
uint8_t p_slotCount
);
// Apply animation start from host
void ApplyAnimStart(uint16_t p_currentAnimIndex);
// Get session view for an animation (nullptr if no session)
const SessionView* GetSessionView(uint16_t p_animIndex) const;
// Check if local player is in a session for this animation
bool IsLocalPlayerInSession(uint16_t p_animIndex) const;
private:
const Catalog* m_catalog;
CoordinationState m_state;
uint16_t m_currentAnimIndex;
uint32_t m_localPeerId;
// When true, a cancel has been sent to the host but not yet confirmed.
// Prevents stale session updates from re-enrolling the local player.
bool m_cancelPending;
// Known sessions from host broadcasts
std::map<uint16_t, SessionView> m_sessions;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,122 @@
#pragma once
#include "extensions/multiplayer/sireader.h"
#include "mxcriticalsection.h"
#include "mxthread.h"
#include "mxwavepresenter.h"
#include <atomic>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
struct FLIC_HEADER;
class LegoAnim;
namespace si
{
class Object;
} // namespace si
namespace Multiplayer::Animation
{
struct SceneAnimData {
LegoAnim* anim;
float duration;
using AudioTrack = Multiplayer::AudioTrack;
std::vector<AudioTrack> audioTracks;
struct PhonemeTrack {
FLIC_HEADER* flcHeader;
std::vector<std::vector<char>> frameData;
uint32_t timeOffset;
std::string roiName;
uint16_t width, height;
};
std::vector<PhonemeTrack> phonemeTracks;
// Action transform from SI metadata (location/direction/up)
struct {
float location[3];
float direction[3];
float up[3];
bool valid;
} actionTransform;
std::vector<std::string> ptAtCamNames; // ROI names from PTATCAM directive
bool hideOnStop;
SceneAnimData();
~SceneAnimData();
SceneAnimData(const SceneAnimData&) = delete;
SceneAnimData& operator=(const SceneAnimData&) = delete;
SceneAnimData(SceneAnimData&& p_other) noexcept;
SceneAnimData& operator=(SceneAnimData&& p_other) noexcept;
private:
void ReleaseTracks();
};
// Loads animation data from SI files on demand.
// Supports multiple worlds' SI files (isle.si, act2main.si, act3.si).
class Loader {
public:
Loader();
~Loader();
void SetSIReader(SIReader* p_reader) { m_reader = p_reader; }
SceneAnimData* EnsureCached(int8_t p_worldId, uint32_t p_objectId);
void PreloadAsync(int8_t p_preloadWorldId, uint32_t p_preloadObjectId);
// Get the SI file path for a world. Returns nullptr if unsupported.
static const char* GetSIPath(int8_t p_worldId);
private:
class PreloadThread : public MxThread {
public:
PreloadThread(Loader* p_loader, int8_t p_worldId, uint32_t p_objectId);
MxResult Run() override;
private:
Loader* m_loader;
int8_t m_worldId;
uint32_t m_objectId;
};
// SI file handle for non-act1 worlds (act1 uses the external SIReader).
struct SIHandle {
si::File* file;
si::Interleaf* interleaf;
bool ready;
};
bool OpenWorldSI(int8_t p_worldId);
bool ReadWorldObject(int8_t p_worldId, uint32_t p_objectId, si::Object*& p_outObj);
static bool ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data);
static bool ParsePhonemeChild(si::Object* p_child, SceneAnimData& p_data);
static bool ParseComposite(si::Object* p_composite, SceneAnimData& p_data);
void CleanupPreloadThread();
static uint64_t CacheKey(int8_t p_worldId, uint32_t p_objectId)
{
return (uint64_t((uint8_t) p_worldId) << 32) | p_objectId;
}
SIReader* m_reader; // external reader for isle.si (act1)
std::map<int8_t, SIHandle> m_extraSI; // SI handles for non-act1 worlds
std::map<uint64_t, SceneAnimData> m_cache; // keyed by CacheKey(worldId, objectId)
MxCriticalSection m_cacheCS;
PreloadThread* m_preloadThread;
int8_t m_preloadWorldId;
uint32_t m_preloadObjectId;
std::atomic<bool> m_preloadDone;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,33 @@
#pragma once
#include <cstdint>
#include <vector>
namespace Multiplayer::Animation
{
static constexpr float NPC_ANIM_PROXIMITY = 15.0f;
class LocationProximity {
public:
LocationProximity();
// Returns true if location set changed since last call
bool Update(float p_x, float p_z);
// All locations within radius (sorted by index for stable comparison)
const std::vector<int16_t>& GetLocations() const { return m_locations; }
bool IsAtLocation(int16_t p_location) const;
float GetRadius() const { return m_radius; }
void Reset();
// Static version returning all locations within radius (sorted by index)
static std::vector<int16_t> ComputeAll(float p_x, float p_z, float p_radius);
private:
float m_radius;
std::vector<int16_t> m_locations;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,39 @@
#pragma once
#include "extensions/multiplayer/animation/loader.h"
#include <cstdint>
#include <vector>
class LegoROI;
class LegoTextureInfo;
class MxBitmap;
namespace Multiplayer::Animation
{
struct PhonemeState {
LegoROI* targetROI;
LegoTextureInfo* originalTexture;
LegoTextureInfo* cachedTexture;
MxBitmap* bitmap;
int32_t currentFrame;
};
class PhonemePlayer {
public:
void Init(
const std::vector<SceneAnimData::PhonemeTrack>& p_tracks,
LegoROI** p_roiMap,
MxU32 p_roiMapSize,
const std::vector<std::pair<std::string, LegoROI*>>& p_actorAliases
);
void Tick(float p_elapsedMs, const std::vector<SceneAnimData::PhonemeTrack>& p_tracks);
void Cleanup();
void NotifyROIDestroyed(LegoROI* p_roi);
private:
std::vector<PhonemeState> m_states;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,106 @@
#pragma once
#include "extensions/multiplayer/animation/audioplayer.h"
#include "extensions/multiplayer/animation/catalog.h"
#include "extensions/multiplayer/animation/loader.h"
#include "extensions/multiplayer/animation/phonemeplayer.h"
#include "mxgeometry/mxmatrix.h"
#include "mxtypes.h"
#include <cstdint>
#include <string>
#include <vector>
class LegoROI;
struct AnimInfo;
namespace Multiplayer::Animation
{
// A participant (local or remote player) whose ROI is borrowed during animation
struct ParticipantROI {
LegoROI* roi;
LegoROI* vehicleROI; // Ride vehicle ROI (bike/board/moto), or nullptr
MxMatrix savedTransform;
std::string savedName;
int8_t charIndex; // g_actorInfoInit[] index, or -1 for spectator
bool IsSpectator() const { return charIndex < 0; }
};
class ScenePlayer {
public:
ScenePlayer();
~ScenePlayer();
// When p_observerMode is false, p_participants[0] must be the local player.
// When p_observerMode is true, participants are only remote performers (no local player).
void Play(
const AnimInfo* p_animInfo,
int8_t p_worldId,
AnimCategory p_category,
const ParticipantROI* p_participants,
uint8_t p_participantCount,
bool p_observerMode = false
);
void Tick();
void Stop();
void NotifyROIDestroyed(LegoROI* p_roi);
bool IsPlaying() const { return m_playing; }
bool IsObserverMode() const { return m_observerMode; }
void SetLoader(Loader* p_loader) { m_loader = p_loader; }
private:
void ComputeRebaseMatrix();
void SetupROIs(const AnimInfo* p_animInfo);
void ResolvePtAtCamROIs();
void ApplyPtAtCam();
void CleanupProps();
// Sub-components
Loader* m_loader;
AudioPlayer m_audioPlayer;
PhonemePlayer m_phonemePlayer;
// Playback state
bool m_playing;
bool m_rebaseComputed;
uint64_t m_startTime;
SceneAnimData* m_currentData;
AnimCategory m_category;
MxMatrix m_animPose0;
MxMatrix m_rebaseMatrix;
// Participants (local player at index 0, remote players after)
std::vector<ParticipantROI> m_participants;
// Root performer ROI (rebase anchor for NPC anims)
LegoROI* m_animRootROI;
// Vehicle ROI borrowed from a participant during playback
LegoROI* m_vehicleROI;
// Player's ride vehicle hidden during cam_anim (not borrowed, just hidden)
LegoROI* m_hiddenVehicleROI;
// ROI map for skeletal animation
LegoROI** m_roiMap;
MxU32 m_roiMapSize;
// Actor name → ROI aliases (participant ROIs whose names differ from animation actor names)
std::vector<std::pair<std::string, LegoROI*>> m_actorAliases;
// Props created for the animation (cloned characters and prop models)
std::vector<LegoROI*> m_propROIs;
// ROIs cloned from scene (created by sharing LOD data, not registered in CharacterManager)
std::vector<LegoROI*> m_clonedSceneROIs;
bool m_hasCamAnim;
bool m_observerMode;
std::vector<LegoROI*> m_ptAtCamROIs;
bool m_hideOnStop;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
#include <map>
#include <vector>
namespace Multiplayer::Animation
{
class Catalog;
struct CatalogEntry;
enum class CoordinationState : uint8_t;
struct SessionSlot {
uint32_t peerId; // 0 = unfilled
int8_t charIndex; // g_actorInfoInit index, or -1 for spectator
bool IsSpectator() const { return IsSpectatorCharIndex(charIndex); }
static bool IsSpectatorCharIndex(int8_t p_charIndex) { return p_charIndex < 0; }
};
struct AnimSession {
uint16_t animIndex;
CoordinationState state;
std::vector<SessionSlot> slots;
uint32_t countdownEndTime; // SDL_GetTicks timestamp when countdown expires
};
class SessionHost {
public:
void SetCatalog(const Catalog* p_catalog);
bool HandleInterest(
uint32_t p_peerId,
uint16_t p_animIndex,
uint8_t p_displayActorIndex,
std::vector<uint16_t>& p_changedAnims
);
bool HandleCancel(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
bool HandlePlayerRemoved(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
// Returns animIndices of all sessions ready to play
std::vector<uint16_t> Tick(uint32_t p_now);
void StartCountdown(uint16_t p_animIndex);
void RevertCountdown(uint16_t p_animIndex);
void Reset();
void EraseSession(uint16_t p_animIndex);
const AnimSession* FindSession(uint16_t p_animIndex) const;
const std::map<uint16_t, AnimSession>& GetSessions() const;
bool AreAllSlotsFilled(uint16_t p_animIndex) const;
static uint16_t ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now);
// Reconstruct slot charIndex assignments from CatalogEntry::performerMask.
// Same iteration order as CreateSession — deterministic across all clients.
static std::vector<int8_t> ComputeSlotCharIndices(const CatalogEntry* p_entry);
bool HasCountdownSession() const;
private:
AnimSession CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex);
bool TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex);
bool AllSlotsFilled(const AnimSession& p_session) const;
void RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
void RemovePlayerFromSessions(
uint32_t p_peerId,
bool p_includePlayingSessions,
std::vector<uint16_t>& p_changedAnims
);
const Catalog* m_catalog = nullptr;
std::map<uint16_t, AnimSession> m_sessions;
static const uint32_t COUNTDOWN_DURATION_MS = 4000;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,59 @@
#pragma once
#include "extensions/common/characteranimator.h"
#include <cstdint>
class LegoAnim;
class LegoROI;
namespace Extensions
{
namespace Common
{
struct PropGroup;
}
} // namespace Extensions
namespace Multiplayer
{
// Per-phase emote data: animation name and optional sound effect.
struct EmotePhase {
const char* anim; // Animation name (nullptr = unused phase)
const char* sound; // Sound key for LegoCacheSoundManager (nullptr = silent)
};
// Emote table entry: two phases (phase 1 = primary, phase 2 = recovery for multi-part emotes).
struct EmoteEntry {
EmotePhase phases[2];
};
extern const EmoteEntry g_emoteEntries[];
extern const int g_emoteAnimCount;
// Returns true if the emote is a multi-part stateful emote (has a phase-2 animation).
inline bool IsMultiPartEmote(uint8_t p_emoteId)
{
return p_emoteId < g_emoteAnimCount && g_emoteEntries[p_emoteId].phases[1].anim != nullptr;
}
// IExtraAnimHandler implementation for emote animations.
// Delegates to the emote table for animation names, sound keys, and multi-part detection.
class EmoteAnimHandler : public Extensions::Common::IExtraAnimHandler {
public:
EmoteAnimHandler() = default;
bool IsValid(uint8_t p_id) const override;
bool IsMultiPart(uint8_t p_id) const override;
const char* GetAnimName(uint8_t p_id, int p_phase) const override;
const char* GetSoundName(uint8_t p_id, int p_phase) const override;
void BuildProps(
Extensions::Common::PropGroup& p_group,
LegoAnim* p_anim,
LegoROI* p_playerROI,
uint32_t p_propSuffix
) override;
};
} // namespace Multiplayer

View File

@ -0,0 +1,47 @@
#pragma once
#include "mxgeometry/mxmatrix.h"
#include "mxtypes.h"
#include <cstdint>
#include <string>
#include <vector>
class LegoROI;
namespace Extensions
{
namespace Common
{
struct CustomizeState;
}
} // namespace Extensions
namespace Multiplayer
{
// Broadcast world ID indicating the player is not visible in any world.
static constexpr int8_t WORLD_NOT_VISIBLE = -1;
// Deep-clone an ROI hierarchy (geometry shared via LOD refcount, independent transforms).
LegoROI* DeepCloneROI(LegoROI* p_source, const char* p_name);
// Compute child-to-parent local offsets for a hierarchical ROI.
std::vector<MxMatrix> ComputeChildOffsets(LegoROI* p_parent);
// Apply a world transform to a parent ROI and recompute child transforms from saved offsets.
void ApplyHierarchyTransform(LegoROI* p_parent, const MxMatrix& p_transform, const std::vector<MxMatrix>& p_offsets);
// Trim trailing digits and underscores from a model name to get its LOD base name.
std::string TrimLODSuffix(const std::string& p_name);
// Serialize a CustomizeState into a 5-byte buffer.
void PackCustomizeState(const Extensions::Common::CustomizeState& p_state, uint8_t p_out[5]);
// Deserialize a CustomizeState from a 5-byte buffer.
void UnpackCustomizeState(Extensions::Common::CustomizeState& p_state, const uint8_t p_in[5]);
// Compare two CustomizeState instances for equality.
bool CustomizeStatesEqual(const Extensions::Common::CustomizeState& p_a, const Extensions::Common::CustomizeState& p_b);
} // namespace Multiplayer

View File

@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
namespace Tgl
{
class Group;
class MeshBuilder;
class Mesh;
class Texture;
} // namespace Tgl
class LegoROI;
namespace Multiplayer
{
class NameBubbleRenderer {
public:
NameBubbleRenderer();
~NameBubbleRenderer();
// Create the 3D billboard with the given name text.
// Must be called after the player's ROI is spawned.
void Create(const char* p_name);
// Remove from scene and release all resources.
void Destroy();
// Update billboard position (above p_roi) and orientation (face camera).
void Update(LegoROI* p_roi);
// Show or hide the billboard.
void SetVisible(bool p_visible);
bool IsCreated() const { return m_group != nullptr; }
private:
void GenerateTexture(const char* p_name);
void CreateQuadMesh();
Tgl::Group* m_group;
Tgl::MeshBuilder* m_meshBuilder;
Tgl::Mesh* m_mesh;
Tgl::Texture* m_texture;
uint8_t* m_texelData;
bool m_visible;
};
} // namespace Multiplayer

View File

@ -0,0 +1,257 @@
#pragma once
#include "extensions/multiplayer/animation/catalog.h"
#include "extensions/multiplayer/animation/coordinator.h"
#include "extensions/multiplayer/animation/locationproximity.h"
#include "extensions/multiplayer/animation/sceneplayer.h"
#include "extensions/multiplayer/animation/sessionhost.h"
#include "extensions/multiplayer/networktransport.h"
#include "extensions/multiplayer/platformcallbacks.h"
#include "extensions/multiplayer/protocol.h"
#include "extensions/multiplayer/remoteplayer.h"
#include "extensions/multiplayer/sireader.h"
#include "extensions/multiplayer/worldstatesync.h"
#include "mxcore.h"
#include "mxtypes.h"
#include <atomic>
#include <cstdint>
#include <map>
#include <memory>
#include <string>
#include <vector>
class LegoEntity;
class LegoWorld;
namespace Extensions
{
class ThirdPersonCameraExt;
}
namespace Multiplayer
{
class NameBubbleRenderer;
class NetworkManager : public MxCore {
public:
enum ConnectionState {
STATE_DISCONNECTED,
STATE_CONNECTED,
STATE_RECONNECTING
};
NetworkManager();
~NetworkManager() override;
MxResult Tickle() override;
const char* ClassName() const override { return "NetworkManager"; }
MxBool IsA(const char* p_name) const override
{
return !SDL_strcmp(p_name, NetworkManager::ClassName()) || MxCore::IsA(p_name);
}
void Initialize(NetworkTransport* p_transport, PlatformCallbacks* p_callbacks);
void HandleCreate();
void Shutdown();
void Connect(const char* p_roomId);
void Disconnect();
bool IsConnected() const;
bool WasRejected() const;
void SetWalkAnimation(uint8_t p_walkAnimId);
void SetIdleAnimation(uint8_t p_idleAnimId);
void SendEmote(uint8_t p_emoteId);
void SendHorn(int8_t p_vehicleType);
// Thread-safe request methods for cross-thread callers (e.g. WASM exports
// running on the browser main thread). Deferred to the game thread in Tickle().
void RequestToggleThirdPerson() { m_pendingToggleThirdPerson.store(true, std::memory_order_relaxed); }
void RequestSetWalkAnimation(uint8_t p_walkAnimId)
{
m_pendingWalkAnim.store(p_walkAnimId, std::memory_order_relaxed);
}
void RequestSetIdleAnimation(uint8_t p_idleAnimId)
{
m_pendingIdleAnim.store(p_idleAnimId, std::memory_order_relaxed);
}
void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); }
void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); }
void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.store(true, std::memory_order_relaxed); }
void RequestSetAnimInterest(int32_t p_animIndex)
{
m_pendingAnimInterest.store(p_animIndex, std::memory_order_relaxed);
}
void RequestCancelAnimInterest() { m_pendingAnimCancel.store(true, std::memory_order_relaxed); }
bool IsInIsleWorld() const { return m_inIsleWorld; }
RemotePlayer* FindPlayerByROI(LegoROI* p_roi) const;
bool IsClonedCharacter(const char* p_name) const;
void SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex);
// Stop any playing animation and release its resources.
// Must be called before the display ROI is destroyed.
void StopAnimation();
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
void OnBeforeSaveLoad();
void OnSaveLoaded();
void NotifyThirdPersonChanged(bool p_enabled);
void NotifyNameBubblesChanged(bool p_enabled);
void NotifyAllowCustomizeChanged(bool p_enabled);
// Called from multiplayer extension when a plant/building entity is clicked.
// Returns TRUE if the mutation should be suppressed locally (non-host).
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
// Called from multiplayer extension when a sky/light control is used.
// Returns TRUE if the local action should be suppressed (non-host).
MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType);
bool IsHost() const { return m_localPeerId != 0 && m_localPeerId == m_hostPeerId; }
uint32_t GetLocalPeerId() const { return m_localPeerId; }
private:
void BroadcastLocalState();
void ProcessIncomingPackets();
void UpdateRemotePlayers(float p_deltaTime);
RemotePlayer* CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex);
void HandleLeave(const PlayerLeaveMsg& p_msg);
void HandleState(const PlayerStateMsg& p_msg);
void HandleHostAssign(const HostAssignMsg& p_msg);
void HandleEmote(const EmoteMsg& p_msg);
void HandleHorn(const HornMsg& p_msg);
void HandleCustomize(const CustomizeMsg& p_msg);
// Animation coordination handlers
void HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex);
void HandleAnimCancel(uint32_t p_peerId);
void HandleAnimUpdate(const AnimUpdateMsg& p_msg);
void HandleAnimStart(const AnimStartMsg& p_msg);
void HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession, uint64_t p_eventId);
AnimUpdateMsg BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target);
void BroadcastAnimUpdate(uint16_t p_animIndex);
void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId);
void BroadcastAnimStart(uint16_t p_animIndex, uint64_t p_eventId);
bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const;
bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const;
bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const;
uint8_t GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const;
bool ValidateSessionLocations(uint16_t p_animIndex);
void ResetAnimationState();
void CancelLocalAnimInterest();
void BroadcastChangedSessions(const std::vector<uint16_t>& p_changedAnims);
void TickHostSessions();
MessageHeader MakeHeader(uint8_t p_type, uint32_t p_target);
void ProcessPendingRequests();
void RemoveRemotePlayer(uint32_t p_peerId);
void RemoveAllRemotePlayers();
void CheckConnectionState();
void AttemptReconnect();
void ResetStateAfterReconnect();
void NotifyPlayerCountChanged();
void EnforceDisableNPCs();
void PushAnimationState();
// Serialize and send a fixed-size message via the transport
template <typename T>
void SendMessage(const T& p_msg);
NetworkTransport* m_transport;
PlatformCallbacks* m_callbacks;
WorldStateSync m_worldSync;
NameBubbleRenderer* m_localNameBubble;
std::map<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers;
std::map<LegoROI*, RemotePlayer*> m_roiToPlayer;
uint32_t m_localPeerId;
uint32_t m_hostPeerId;
uint32_t m_sequence;
uint32_t m_lastBroadcastTime;
uint8_t m_lastValidActorId;
bool m_localAllowRemoteCustomize;
bool m_inIsleWorld;
bool m_registered;
std::atomic<bool> m_pendingToggleThirdPerson;
std::atomic<bool> m_pendingToggleNameBubbles;
std::atomic<int> m_pendingWalkAnim;
std::atomic<int> m_pendingIdleAnim;
std::atomic<int> m_pendingEmote;
std::atomic<bool> m_pendingToggleAllowCustomize;
std::atomic<int32_t> m_pendingAnimInterest;
std::atomic<bool> m_pendingAnimCancel;
bool m_showNameBubbles;
bool m_lastCameraEnabled;
uint8_t m_lastVehicleState;
bool m_wasInRestrictedArea;
// SI file reader (shared with animation loader)
SIReader m_siReader;
// NPC animation playback
Multiplayer::Animation::Catalog m_animCatalog;
Multiplayer::Animation::Loader m_animLoader;
Multiplayer::Animation::LocationProximity m_locationProximity;
Multiplayer::Animation::Coordinator m_animCoordinator;
Multiplayer::Animation::SessionHost m_animSessionHost;
int32_t m_localPendingAnimInterest;
// Concurrent animation playback: one ScenePlayer per playing animation
std::map<uint16_t, std::unique_ptr<Multiplayer::Animation::ScenePlayer>> m_playingAnims;
// Pre-built completion JSON per playing animation (non-observer participants only).
// Cached at animation start so it survives host migration/dropout.
std::map<uint16_t, std::string> m_pendingCompletionJson;
std::string BuildCompletionJson(uint16_t p_animIndex, uint64_t p_eventId);
void TickAnimation();
void StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes);
void StopAllPlayback();
void NotifyAnimationsROIDestroyed(RemotePlayer* p_player);
void UnlockRemotesForAnim(uint16_t p_animIndex);
// Horn sound synchronization
void PreloadHornSounds();
void CleanupHornSounds();
// Animation state push
bool m_animStateDirty;
bool m_animInterestDirty;
uint32_t m_lastAnimPushTime;
ConnectionState m_connectionState;
bool m_wasRejected;
std::string m_roomId;
uint32_t m_reconnectAttempt;
uint32_t m_reconnectDelay;
uint32_t m_nextReconnectTime;
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000;
static const uint32_t RECONNECT_MAX_DELAY_MS = 30000;
static const uint32_t RECONNECT_MAX_ATTEMPTS = 10;
static const uint32_t ANIM_PUSH_COOLDOWN_MS = 250; // max ~4Hz for movement-based changes
// Horn sound data
static const int HORN_VEHICLE_COUNT = 4;
class LegoCacheSound* m_hornTemplates[HORN_VEHICLE_COUNT];
std::vector<class LegoCacheSound*> m_activeHorns;
};
} // namespace Multiplayer

View File

@ -0,0 +1,28 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
namespace Multiplayer
{
class NetworkTransport {
public:
virtual ~NetworkTransport() = default;
virtual void Connect(const char* p_roomId) = 0;
virtual void Disconnect() = 0;
virtual bool IsConnected() const = 0;
virtual bool WasDisconnected() const = 0;
virtual bool WasRejected() const = 0;
// Send binary data to all peers via relay
virtual void Send(const uint8_t* p_data, size_t p_length) = 0;
// Drain received messages. Callback called for each message.
// Returns number of messages dequeued.
virtual size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) = 0;
};
} // namespace Multiplayer

View File

@ -0,0 +1,43 @@
#pragma once
#include <cstdint>
namespace Multiplayer
{
static const int CONNECTION_STATUS_CONNECTED = 0;
static const int CONNECTION_STATUS_RECONNECTING = 1;
static const int CONNECTION_STATUS_FAILED = 2;
static const int CONNECTION_STATUS_REJECTED = 3;
class PlatformCallbacks {
public:
virtual ~PlatformCallbacks() = default;
// Called when the visible player count changes (joins, leaves, world transitions).
// p_count = players visible in current world, or -1 if not in a multiplayer world.
virtual void OnPlayerCountChanged(int p_count) = 0;
// Called when the third-person camera mode changes (toggle or auto-switch).
virtual void OnThirdPersonChanged(bool p_enabled) = 0;
// Called when name bubbles visibility changes.
virtual void OnNameBubblesChanged(bool p_enabled) = 0;
// Called when the allow-customization setting changes.
virtual void OnAllowCustomizeChanged(bool p_enabled) = 0;
// Called when the connection status changes (connected, reconnecting, failed).
virtual void OnConnectionStatusChanged(int p_status) = 0;
// Called when animation eligibility state changes (location change, player join/leave, etc.).
// p_json = JSON payload with location, coordinator state, and per-animation slot fill status.
virtual void OnAnimationsAvailable(const char* p_json) = 0;
// Called when an animation completes successfully (natural completion, not cancellation).
// Only fired for actual participants, not observers.
// p_json = JSON with eventId, animIndex, and participant details (charIndex, displayName).
virtual void OnAnimationCompleted(const char* p_json) = 0;
};
} // namespace Multiplayer

View File

@ -0,0 +1,23 @@
#pragma once
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/platformcallbacks.h"
namespace Multiplayer
{
class EmscriptenCallbacks : public PlatformCallbacks {
public:
void OnPlayerCountChanged(int p_count) override;
void OnThirdPersonChanged(bool p_enabled) override;
void OnNameBubblesChanged(bool p_enabled) override;
void OnAllowCustomizeChanged(bool p_enabled) override;
void OnConnectionStatusChanged(int p_status) override;
void OnAnimationsAvailable(const char* p_json) override;
void OnAnimationCompleted(const char* p_json) override;
};
} // namespace Multiplayer
#endif // __EMSCRIPTEN__

View File

@ -0,0 +1,36 @@
#pragma once
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/networktransport.h"
#include <string>
namespace Multiplayer
{
class WebSocketTransport : public NetworkTransport {
public:
WebSocketTransport(const std::string& p_relayBaseUrl);
~WebSocketTransport() override;
void Connect(const char* p_roomId) override;
void Disconnect() override;
bool IsConnected() const override;
bool WasDisconnected() const override;
bool WasRejected() const override;
void Send(const uint8_t* p_data, size_t p_length) override;
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
private:
std::string m_relayBaseUrl;
int m_socketId;
volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics
volatile int32_t m_disconnectedFlag; // Set by JS when connection closes (room full or lost)
volatile int32_t m_wasEverConnected; // Set once in onopen, never cleared by error/close
uint8_t m_recvBuf[8192];
};
} // namespace Multiplayer
#endif // __EMSCRIPTEN__

View File

@ -0,0 +1,72 @@
#pragma once
#ifndef __EMSCRIPTEN__
#include "extensions/multiplayer/networktransport.h"
#include "mxcriticalsection.h"
#include "mxthread.h"
#include <atomic>
#include <deque>
#include <string>
#include <vector>
struct lws_context;
struct lws;
namespace Multiplayer
{
class LwsTransport;
class LwsServiceThread : public MxThread {
public:
LwsServiceThread() : m_transport(nullptr) {}
MxResult Run() override;
void SetTransport(LwsTransport* p_transport) { m_transport = p_transport; }
private:
LwsTransport* m_transport;
};
class LwsTransport : public NetworkTransport {
friend class LwsServiceThread;
public:
LwsTransport(const std::string& p_relayBaseUrl);
~LwsTransport() override;
void Connect(const char* p_roomId) override;
void Disconnect() override;
bool IsConnected() const override;
bool WasDisconnected() const override;
bool WasRejected() const override;
void Send(const uint8_t* p_data, size_t p_length) override;
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
// Called from static lws callback trampoline
int HandleLwsEvent(struct lws* p_wsi, int p_reason, void* p_in, size_t p_len);
private:
void ServiceLoop();
std::string m_relayBaseUrl;
struct lws_context* m_context;
std::atomic<struct lws*> m_wsi;
std::atomic<bool> m_connected;
std::atomic<bool> m_disconnected;
std::atomic<bool> m_wasEverConnected;
MxCriticalSection m_sendCS;
MxCriticalSection m_recvCS;
std::deque<std::vector<uint8_t>> m_sendQueue;
std::deque<std::vector<uint8_t>> m_recvQueue;
std::vector<uint8_t> m_fragment;
LwsServiceThread* m_serviceThread;
std::atomic<bool> m_wantWritable;
};
} // namespace Multiplayer
#endif // !__EMSCRIPTEN__

View File

@ -0,0 +1,23 @@
#pragma once
#ifndef __EMSCRIPTEN__
#include "extensions/multiplayer/platformcallbacks.h"
namespace Multiplayer
{
class NativeCallbacks : public PlatformCallbacks {
public:
void OnPlayerCountChanged(int p_count) override;
void OnThirdPersonChanged(bool p_enabled) override;
void OnNameBubblesChanged(bool p_enabled) override;
void OnAllowCustomizeChanged(bool p_enabled) override;
void OnConnectionStatusChanged(int p_status) override;
void OnAnimationsAvailable(const char* p_json) override;
void OnAnimationCompleted(const char* p_json) override;
};
} // namespace Multiplayer
#endif // !__EMSCRIPTEN__

View File

@ -0,0 +1,266 @@
#pragma once
#include "extensions/common/constants.h"
#include "extensions/multiplayer/networktransport.h"
#include <SDL3/SDL_stdinc.h>
#include <cstddef>
#include <cstdint>
#include <type_traits>
namespace Multiplayer
{
static constexpr size_t USERNAME_BUFFER_SIZE = 8; // 7 chars + null terminator
// Routing target constants for MessageHeader.target
const uint32_t TARGET_BROADCAST = 0; // Broadcast to all except sender
const uint32_t TARGET_HOST = 0xFFFFFFFF; // Send to host only
const uint32_t TARGET_BROADCAST_ALL = 0xFFFFFFFE; // Broadcast to all including sender
enum MessageType : uint8_t {
MSG_LEAVE = 2,
MSG_STATE = 3,
MSG_HOST_ASSIGN = 4,
MSG_REQUEST_SNAPSHOT = 5,
MSG_WORLD_SNAPSHOT = 6,
MSG_WORLD_EVENT = 7,
MSG_WORLD_EVENT_REQUEST = 8,
MSG_EMOTE = 9,
MSG_CUSTOMIZE = 10,
MSG_ANIM_INTEREST = 11,
MSG_ANIM_CANCEL = 12,
MSG_ANIM_UPDATE = 13,
MSG_ANIM_START = 14,
MSG_HORN = 16,
MSG_ASSIGN_ID = 0xFF
};
using Extensions::Common::VEHICLE_AMBULANCE;
using Extensions::Common::VEHICLE_BIKE;
using Extensions::Common::VEHICLE_COUNT;
using Extensions::Common::VEHICLE_DUNEBUGGY;
using Extensions::Common::VEHICLE_HELICOPTER;
using Extensions::Common::VEHICLE_JETSKI;
using Extensions::Common::VEHICLE_MOTOCYCLE;
using Extensions::Common::VEHICLE_NONE;
using Extensions::Common::VEHICLE_SKATEBOARD;
using Extensions::Common::VEHICLE_TOWTRACK;
using Extensions::Common::VehicleType;
// Entity types for world events
enum WorldEntityType : uint8_t {
ENTITY_PLANT = 0,
ENTITY_BUILDING = 1,
ENTITY_SKY = 2,
ENTITY_LIGHT = 3
};
using Extensions::Common::CHANGE_COLOR;
using Extensions::Common::CHANGE_DECREMENT;
using Extensions::Common::CHANGE_MOOD;
using Extensions::Common::CHANGE_MOVE;
using Extensions::Common::CHANGE_SOUND;
using Extensions::Common::CHANGE_VARIANT;
using Extensions::Common::WorldChangeType;
// Change types for ENTITY_SKY
enum SkyChangeType : uint8_t {
SKY_TOGGLE_COLOR = 0,
SKY_DAY = 1,
SKY_NIGHT = 2
};
// Change types for ENTITY_LIGHT
enum LightChangeType : uint8_t {
LIGHT_INCREMENT = 0,
LIGHT_DECREMENT = 1
};
#pragma pack(push, 1)
struct MessageHeader {
uint8_t type;
uint8_t _pad;
uint32_t peerId;
uint32_t sequence;
uint32_t target;
};
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;
uint8_t walkAnimId; // Index into walk animation table (0 = default)
uint8_t idleAnimId; // Index into idle animation table (0 = default)
char name[USERNAME_BUFFER_SIZE]; // Player display name (7 chars + null terminator)
uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65)
uint8_t customizeData[5]; // Packed CustomizeState
uint8_t customizeFlags; // Bit 0 = allowRemoteCustomize
};
// Server -> all: announces which peer is the host
struct HostAssignMsg {
MessageHeader header;
uint32_t hostPeerId;
};
// Client -> host: request full world state snapshot
struct RequestSnapshotMsg {
MessageHeader header;
};
// Host -> specific client: full world state blob (variable length)
// Relay reads header.target and routes to that peer only.
struct WorldSnapshotMsg {
MessageHeader header;
uint16_t dataLength;
// Followed by dataLength bytes of serialized plant + building state
};
// Host -> all: single world state mutation
struct WorldEventMsg {
MessageHeader header;
uint8_t entityType; // WorldEntityType
uint8_t changeType; // WorldChangeType
uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[]
uint8_t padding; // Alignment
};
// Non-host -> host: request a mutation (same layout as WorldEventMsg)
struct WorldEventRequestMsg {
MessageHeader header;
uint8_t entityType; // WorldEntityType
uint8_t changeType; // WorldChangeType
uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[]
uint8_t padding; // Alignment
};
// One-shot emote trigger, broadcast to all peers
struct EmoteMsg {
MessageHeader header;
uint8_t emoteId; // Index into emote table
};
// One-shot horn sound trigger, broadcast to all peers
struct HornMsg {
MessageHeader header;
uint8_t vehicleType; // VehicleType enum value
};
// Immediate customization change, broadcast to all peers
struct CustomizeMsg {
MessageHeader header;
uint32_t targetPeerId; // Who is being customized
uint8_t changeType; // WorldChangeType (VARIANT/SOUND/MOVE/COLOR/MOOD)
uint8_t partIndex; // Body part for color changes (0-9), 0xFF otherwise
};
// Client -> Host: express interest in an animation slot
struct AnimInterestMsg {
MessageHeader header;
uint16_t animIndex;
uint8_t displayActorIndex;
};
// Client -> Host: cancel interest in current animation
struct AnimCancelMsg {
MessageHeader header;
};
// Per-slot assignment in AnimUpdateMsg
struct AnimSlotAssignment {
uint32_t peerId; // 0 = unfilled
};
// Host -> All: authoritative session state update
struct AnimUpdateMsg {
MessageHeader header;
uint16_t animIndex;
uint8_t state; // CoordinationState (0=cleared, 1=gathering, 2=countdown, 3=playing)
uint16_t countdownMs; // Remaining countdown ms (0 if not counting)
uint8_t slotCount; // Number of valid slot entries
AnimSlotAssignment slots[8]; // peerId per slot (0 = unfilled)
};
// Host -> All: animation playback trigger
struct AnimStartMsg {
MessageHeader header;
uint16_t animIndex;
uint64_t eventId; // Random 64-bit ID for the completion event (host-generated, same for all clients)
};
#pragma pack(pop)
// Bitmask constants for PlayerStateMsg::customizeFlags
static constexpr uint8_t CUSTOMIZE_FLAG_ALLOW_REMOTE = 0x01;
static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN = 0x02;
static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN_EMOTE_SHIFT = 2;
static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN_EMOTE_MASK = 0x07;
using Extensions::Common::IsValidActorId;
// Convert LegoGameState::Username letter indices (0-25 = A-Z) to ASCII.
// Writes up to 7 characters + null terminator into p_out (must be at least 8 bytes).
void EncodeUsername(char p_out[USERNAME_BUFFER_SIZE]);
using Extensions::Common::DISPLAY_ACTOR_NONE;
// 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];
}
// Generic serialization: copy a packed message struct into a buffer.
template <typename T>
inline size_t SerializeMsg(uint8_t* p_buf, size_t p_bufLen, const T& p_msg)
{
static_assert(std::is_trivially_copyable_v<T>);
if (p_bufLen < sizeof(T)) {
return 0;
}
SDL_memcpy(p_buf, &p_msg, sizeof(T));
return sizeof(T);
}
// Generic deserialization: copy raw bytes into a packed message struct.
template <typename T>
inline bool DeserializeMsg(const uint8_t* p_data, size_t p_length, T& p_out)
{
static_assert(std::is_trivially_copyable_v<T>);
if (p_length < sizeof(T)) {
return false;
}
SDL_memcpy(&p_out, p_data, sizeof(T));
return true;
}
// Serialize and send a fixed-size message via the transport.
template <typename T>
inline void SendFixedMessage(NetworkTransport* p_transport, const T& p_msg)
{
if (!p_transport || !p_transport->IsConnected()) {
return;
}
uint8_t buf[sizeof(T)];
size_t len = SerializeMsg(buf, sizeof(buf), p_msg);
if (len > 0) {
p_transport->Send(buf, len);
}
}
} // namespace Multiplayer

View File

@ -0,0 +1,126 @@
#pragma once
#include "extensions/common/characteranimator.h"
#include "extensions/common/customizestate.h"
#include "extensions/multiplayer/animation/catalog.h"
#include "extensions/multiplayer/emoteanimhandler.h"
#include "extensions/multiplayer/protocol.h"
#include "mxgeometry/mxmatrix.h"
#include "mxtypes.h"
#include <cstdint>
#include <string>
#include <vector>
class LegoROI;
class LegoWorld;
namespace Multiplayer
{
class NameBubbleRenderer;
class RemotePlayer {
public:
RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex);
~RemotePlayer();
void Spawn(LegoWorld* p_isleWorld);
void Despawn();
void UpdateFromNetwork(const PlayerStateMsg& p_msg);
void Tick(float p_deltaTime);
void ReAddToScene();
uint32_t GetPeerId() const { return m_peerId; }
const char* GetUniqueName() const { return m_uniqueName; }
uint8_t GetActorId() const { return m_actorId; }
uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; }
void SetActorId(uint8_t p_actorId) { m_actorId = p_actorId; }
LegoROI* GetROI() const { return m_roi; }
bool IsSpawned() const { return m_spawned; }
bool IsVisible() const { return m_visible; }
int8_t GetWorldId() const { return m_targetWorldId; }
const std::vector<int16_t>& GetLocations() const { return m_locations; }
void SetLocations(std::vector<int16_t> p_locations) { m_locations = std::move(p_locations); }
bool IsAtLocation(int16_t p_location) const;
bool HasReceivedUpdate() const { return m_hasReceivedUpdate; }
void GetTargetPosition(float& p_x, float& p_z) const
{
p_x = m_targetPosition[0];
p_z = m_targetPosition[2];
}
uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; }
void SetVisible(bool p_visible);
void TriggerExtraAnim(uint8_t p_emoteId);
void SetNameBubbleVisible(bool p_visible);
void CreateNameBubble();
void DestroyNameBubble();
const Extensions::Common::CustomizeState& GetCustomizeState() const { return m_customizeState; }
bool GetAllowRemoteCustomize() const { return m_allowRemoteCustomize; }
void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); }
void StopClickAnimation();
bool IsInVehicle() const { return m_animator.IsInVehicle(); }
LegoROI* GetRideVehicleROI() const { return m_animator.GetRideVehicleROI(); }
bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; }
bool IsExtraAnimBlocking() const { return m_animator.IsExtraAnimBlocking(); }
const char* GetDisplayName() const { return m_displayName; }
void LockForAnimation(uint16_t p_lockedForAnimIndex) { m_lockedForAnimIndex = p_lockedForAnimIndex; }
void UnlockFromAnimation(uint16_t p_lockedForAnimIndex)
{
if (m_lockedForAnimIndex == p_lockedForAnimIndex) {
m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE;
}
}
void ForceUnlockAnimation() { m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE; }
bool IsAnimationLocked() const { return m_lockedForAnimIndex != Animation::ANIM_INDEX_NONE; }
private:
bool IsEffectivelyMoving() const;
const char* GetDisplayActorName() const;
void UpdateTransform(float p_deltaTime);
void UpdateVehicleState();
void EnterVehicle(int8_t p_vehicleType);
void ExitVehicle();
uint32_t m_peerId;
uint8_t m_actorId;
uint8_t m_displayActorIndex;
char m_uniqueName[32];
char m_displayName[USERNAME_BUFFER_SIZE];
LegoROI* m_roi;
bool m_spawned;
bool m_visible;
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;
std::vector<int16_t> m_locations;
float m_currentPosition[3];
float m_currentDirection[3];
float m_currentUp[3];
Multiplayer::EmoteAnimHandler m_emoteHandler;
Extensions::Common::CharacterAnimator m_animator;
LegoROI* m_vehicleROI;
bool m_vehicleROICloned;
std::vector<MxMatrix> m_vehicleChildOffsets; // child-to-parent local offsets for cloned hierarchical ROIs
NameBubbleRenderer* m_nameBubble;
Extensions::Common::CustomizeState m_customizeState;
bool m_allowRemoteCustomize;
uint16_t m_lockedForAnimIndex;
};
} // namespace Multiplayer

View File

@ -0,0 +1,66 @@
#pragma once
#include "mxcriticalsection.h"
#include "mxwavepresenter.h"
#include <cstdint>
#include <string>
namespace si
{
class File;
class Interleaf;
class Object;
} // namespace si
namespace Multiplayer
{
struct AudioTrack {
MxU8* pcmData;
MxU32 pcmDataSize;
MxWavePresenter::WaveFormat format;
std::string mediaSrcPath;
int32_t volume;
uint32_t timeOffset;
};
// Reads objects from an SI archive on demand, bypassing the streaming pipeline.
// Reads only the RIFF header + offset table on first open, then seeks to
// individual objects as requested.
class SIReader {
public:
SIReader();
~SIReader();
// Open isle.si with header-only read (lazy object loading)
bool Open();
// Open any SI file with header-only read (for background threads with independent handles)
static bool OpenHeaderOnly(const char* p_siPath, si::File*& p_file, si::Interleaf*& p_interleaf);
// Lazy-load a single SI object by index
bool ReadObject(uint32_t p_objectId);
// Get a previously loaded object (must call ReadObject first)
si::Object* GetObject(uint32_t p_objectId);
// Extract WAV audio from a single SI child object
static bool ExtractAudioTrack(si::Object* p_child, AudioTrack& p_out);
// Extract the first WAV child from a composite SI object.
// Opens SI if needed, reads the object, finds first WAV child, extracts audio.
// Caller owns the returned pointer and its pcmData buffer.
AudioTrack* ExtractFirstAudio(uint32_t p_objectId);
bool IsReady() const { return m_siReady; }
si::Interleaf* GetInterleaf() { return m_interleaf; }
private:
si::File* m_siFile;
si::Interleaf* m_interleaf;
bool m_siReady;
MxCriticalSection m_cs;
};
} // namespace Multiplayer

View File

@ -0,0 +1,78 @@
#pragma once
#include "extensions/multiplayer/networktransport.h"
#include "extensions/multiplayer/protocol.h"
#include "mxtypes.h"
#include <cstdint>
#include <string>
#include <vector>
class LegoEntity;
namespace Multiplayer
{
class WorldStateSync {
public:
WorldStateSync();
void SetTransport(NetworkTransport* p_transport) { m_transport = p_transport; }
void SetLocalPeerId(uint32_t p_localPeerId) { m_localPeerId = p_localPeerId; }
void SetHost(bool p_isHost) { m_isHost = p_isHost; }
void SetInIsleWorld(bool p_inIsleWorld) { m_inIsleWorld = p_inIsleWorld; }
// Called when the host peer changes. Requests a snapshot if we're not host.
void OnHostChanged();
// Captures current sky/light state before a save load (for non-host restore).
void SaveSkyLightState();
// Restores previously saved sky/light state (non-host only, prevents flicker).
void RestoreSkyLightState();
// Sends a snapshot to a specific peer, or broadcasts to all if p_targetPeerId is 0.
void SendWorldSnapshotTo(uint32_t p_targetPeerId);
// Incoming message handlers (called from NetworkManager::ProcessIncomingPackets)
void HandleRequestSnapshot(const RequestSnapshotMsg& p_msg);
void HandleWorldSnapshot(const uint8_t* p_data, size_t p_length);
void HandleWorldEvent(const WorldEventMsg& p_msg);
void HandleWorldEventRequest(const WorldEventRequestMsg& p_msg);
// Called from multiplayer extension when a plant/building entity is clicked.
// Returns TRUE if the mutation should be suppressed locally (non-host).
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
// Called from multiplayer extension when a sky/light control is used.
// Returns TRUE if the local action should be suppressed (non-host).
MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType);
// Resets session state for reconnection (peer ID, sequence, host, pending events).
void ResetForReconnect();
private:
void ApplySkyLightState(const char* p_skyColor, int p_lightPos);
void SendSnapshotRequest();
void SendWorldSnapshot(uint32_t p_targetPeerId);
void BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
void SendWorldEventRequest(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
void ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
template <typename T>
void SendMessage(const T& p_msg);
NetworkTransport* m_transport;
uint32_t m_localPeerId;
uint32_t m_sequence;
bool m_isHost;
bool m_inIsleWorld;
bool m_snapshotRequested;
std::vector<WorldEventMsg> m_pendingWorldEvents;
// Saved sky/light state for non-host restore across save loads.
std::string m_savedSkyColor;
int m_savedLightPos;
};
} // namespace Multiplayer

View File

@ -21,10 +21,10 @@ class SiLoaderExt {
static void Initialize();
static bool Load();
static std::optional<MxCore*> HandleFind(StreamObject p_object, LegoWorld* world);
static std::optional<MxCore*> HandleFind(StreamObject p_object, LegoWorld* p_world);
static std::optional<MxResult> HandleStart(MxDSAction& p_action);
static MxBool HandleWorld(LegoWorld* p_world);
static std::optional<MxBool> HandleRemove(StreamObject p_object, LegoWorld* world);
static std::optional<MxBool> HandleRemove(StreamObject p_object, LegoWorld* p_world);
static std::optional<MxBool> HandleDelete(MxDSAction& p_action);
static MxBool HandleEndAction(MxEndActionNotificationParam& p_param);

View File

@ -62,19 +62,20 @@ class Controller {
// Signal that an external animation is active.
// p_lockDisplay: true if the display ROI is being driven by the animation (performer),
// false if just spectating (idle anim continues).
// false if just spectating (free to move).
// p_onStop is called before the display ROI is destroyed (Deactivate/OnWorldDisabled).
void SetAnimPlaying(
bool p_animPlaying,
bool p_lockDisplay = true,
bool p_animLockDisplay = true,
std::function<void()> p_animStopCallback = nullptr
)
{
m_animPlaying = p_animPlaying;
m_animLockDisplay = p_animPlaying && p_lockDisplay;
m_animLockDisplay = p_animPlaying && p_animLockDisplay;
m_animStopCallback = p_animPlaying ? std::move(p_animStopCallback) : nullptr;
}
bool IsAnimPlaying() const { return m_animPlaying; }
bool IsAnimLockDisplay() const { return m_animLockDisplay; }
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
@ -95,7 +96,7 @@ class Controller {
bool IsLeftButtonHeld() const { return m_input.IsLeftButtonHeld(); }
bool IsLmbForwardEngaged() const { return m_lmbForwardEngaged; }
void SetLmbForwardEngaged(bool p_engaged) { m_lmbForwardEngaged = p_engaged; }
void SetLmbForwardEngaged(bool p_lmbForwardEngaged) { m_lmbForwardEngaged = p_lmbForwardEngaged; }
MxBool HandleFirstPersonForward(
LegoNavController* p_nav,
@ -111,11 +112,11 @@ class Controller {
void ResetTouchState() { m_input.ResetTouchState(); }
void SuppressGestures() { m_input.SuppressGestures(); }
bool TryClaimFinger(const SDL_TouchFingerEvent& event) { return m_input.TryClaimFinger(event); }
bool TryReleaseFinger(SDL_FingerID id) { return m_input.TryReleaseFinger(id); }
bool IsFingerTracked(SDL_FingerID id) const { return m_input.IsFingerTracked(id); }
bool TryClaimFinger(const SDL_TouchFingerEvent& p_event) { return m_input.TryClaimFinger(p_event); }
bool TryReleaseFinger(SDL_FingerID p_id) { return m_input.TryReleaseFinger(p_id); }
bool IsFingerTracked(SDL_FingerID p_id) const { return m_input.IsFingerTracked(p_id); }
int GetTouchCount() const { return m_input.GetTouchCount(); }
SDL_FingerID GetFingerID(int idx) const { return m_input.GetFingerID(idx); }
SDL_FingerID GetFingerID(int p_idx) const { return m_input.GetFingerID(p_idx); }
void FreezeDisplayActor() { m_display.FreezeDisplayActor(); }
void UnfreezeDisplayActor() { m_display.UnfreezeDisplayActor(); }
@ -129,7 +130,7 @@ class Controller {
private:
void CancelExternalAnim();
void Deactivate();
void ReinitForCharacter();
void ReinitForCharacter(bool p_preserveCamera = false);
OrbitCamera m_orbit;
InputHandler m_input;

View File

@ -45,8 +45,8 @@ class OrbitCamera {
void AdjustDistance(float p_delta) { m_orbitDistance += p_delta; }
float GetOrbitDistance() const { return m_orbitDistance; }
void SetOrbitDistance(float p_distance) { m_orbitDistance = p_distance; }
float GetSmoothedSpeed() const { return m_smoothedSpeed; }
void SetOrbitDistance(float p_orbitDistance) { m_orbitDistance = p_orbitDistance; }
void ResetSmoothedSpeed() { m_smoothedSpeed = 0.0f; }
static constexpr float DEFAULT_ORBIT_YAW = 0.0f;
static constexpr float DEFAULT_ORBIT_PITCH = 0.3f;

View File

@ -214,23 +214,29 @@ void CharacterAnimator::SetIdleAnimId(uint8_t p_idleAnimId, LegoROI* p_roi)
}
}
void CharacterAnimator::StartExtraAnimPhase(uint8_t p_id, int p_phaseIndex, AnimCache* p_cache, LegoROI* p_roi)
void CharacterAnimator::StartExtraAnimPhase(
uint8_t p_currentExtraAnimId,
int p_phaseIndex,
AnimCache* p_extraAnimCache,
LegoROI* p_roi
)
{
StopClickAnimation();
ClearPropGroup(m_extraAnimPropGroup);
m_currentExtraAnimId = p_id;
m_extraAnimCache = p_cache;
m_currentExtraAnimId = p_currentExtraAnimId;
m_extraAnimCache = p_extraAnimCache;
m_extraAnimTime = 0.0f;
m_extraAnimDuration = (float) p_cache->anim->GetDuration();
m_extraAnimDuration = (float) p_extraAnimCache->anim->GetDuration();
m_extraAnimActive = true;
if (m_config.extraAnimHandler) {
m_config.extraAnimHandler->BuildProps(m_extraAnimPropGroup, p_cache->anim, p_roi, m_config.propSuffix);
m_config.extraAnimHandler->BuildProps(m_extraAnimPropGroup, p_extraAnimCache->anim, p_roi, m_config.propSuffix);
}
const char* sound =
m_config.extraAnimHandler ? m_config.extraAnimHandler->GetSoundName(p_id, p_phaseIndex) : nullptr;
const char* sound = m_config.extraAnimHandler
? m_config.extraAnimHandler->GetSoundName(p_currentExtraAnimId, p_phaseIndex)
: nullptr;
if (sound) {
PlayROISound(sound, p_roi);
}
@ -388,13 +394,14 @@ void CharacterAnimator::InitAnimCaches(LegoROI* p_roi)
}
}
void CharacterAnimator::SetFrozenExtraAnimId(int8_t p_id, LegoROI* p_roi)
void CharacterAnimator::SetFrozenExtraAnimId(int8_t p_frozenExtraAnimId, LegoROI* p_roi)
{
if (m_config.extraAnimHandler && p_id >= 0 && m_config.extraAnimHandler->IsValid((uint8_t) p_id) &&
m_config.extraAnimHandler->IsMultiPart((uint8_t) p_id)) {
const char* animName = m_config.extraAnimHandler->GetAnimName((uint8_t) p_id, 0);
if (m_config.extraAnimHandler && p_frozenExtraAnimId >= 0 &&
m_config.extraAnimHandler->IsValid((uint8_t) p_frozenExtraAnimId) &&
m_config.extraAnimHandler->IsMultiPart((uint8_t) p_frozenExtraAnimId)) {
const char* animName = m_config.extraAnimHandler->GetAnimName((uint8_t) p_frozenExtraAnimId, 0);
AnimCache* cache = (p_roi && animName) ? GetOrBuildAnimCache(p_roi, animName) : nullptr;
m_frozenExtraAnimId = p_id;
m_frozenExtraAnimId = p_frozenExtraAnimId;
m_frozenAnimCache = cache;
m_frozenAnimDuration = (cache && cache->anim) ? (float) cache->anim->GetDuration() : 0.0f;
m_extraAnimActive = false;

View File

@ -18,7 +18,7 @@ using namespace Extensions::Common;
LegoROI* CharacterCloner::Clone(LegoCharacterManager* p_charMgr, const char* p_uniqueName, const char* p_characterType)
{
MxBool success = FALSE;
LegoROI* roi = NULL;
LegoROI* roi = nullptr;
BoundingSphere boundingSphere;
BoundingBox boundingBox;
MxMatrix mat;
@ -30,7 +30,7 @@ LegoROI* CharacterCloner::Clone(LegoCharacterManager* p_charMgr, const char* p_u
LegoTextureContainer* textureContainer = TextureContainer();
LegoActorInfo* info = p_charMgr->GetActorInfo(p_characterType);
if (info == NULL) {
if (info == nullptr) {
goto done;
}
@ -115,7 +115,7 @@ LegoROI* CharacterCloner::Clone(LegoCharacterManager* p_charMgr, const char* p_u
LegoTextureInfo* textureInfo = textureContainer->Get(part.m_names[part.m_nameIndices[part.m_nameIndex]]);
if (textureInfo != NULL) {
if (textureInfo != nullptr) {
childROI->SetTextureInfo(textureInfo);
childROI->SetLodColor(1.0F, 1.0F, 1.0F, 0.0F);
}
@ -147,9 +147,9 @@ LegoROI* CharacterCloner::Clone(LegoCharacterManager* p_charMgr, const char* p_u
success = TRUE;
done:
if (!success && roi != NULL) {
if (!success && roi != nullptr) {
delete roi;
roi = NULL;
roi = nullptr;
}
return roi;

View File

@ -46,7 +46,7 @@ LegoROI* CharacterCustomizer::FindChildROI(LegoROI* p_rootROI, const char* p_nam
}
}
return NULL;
return nullptr;
}
// MARK: Public API
@ -253,7 +253,7 @@ void CharacterCustomizer::ApplyHatVariant(LegoROI* p_rootROI, uint8_t p_actorInf
LegoROI* childROI = FindChildROI(p_rootROI, g_actorLODs[c_infohatLOD].m_name);
if (childROI != NULL) {
if (childROI != nullptr) {
char lodName[256];
ViewLODList* lodList = GetViewLODListManager()->Lookup(part.m_partName[partNameIndex]);

View File

@ -32,15 +32,15 @@ const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0])
const char* const g_vehicleROINames[VEHICLE_COUNT] =
{"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"};
// Ride animation names for small vehicles (NULL = large vehicle, no ride anim)
const char* const g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL};
// Ride animation names for small vehicles (nullptr = large vehicle, no ride anim)
const char* const g_rideAnimNames[VEHICLE_COUNT] = {nullptr, nullptr, nullptr, "CNs001Bd", "CNs001sk", "CNs011Ni", nullptr, nullptr};
// Vehicle variant ROI names used in ride animations
const char* const g_rideVehicleROINames[VEHICLE_COUNT] = {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL};
const char* const g_rideVehicleROINames[VEHICLE_COUNT] = {nullptr, nullptr, nullptr, "bikebd", "board", "motoni", nullptr, nullptr};
bool IsLargeVehicle(int8_t p_vehicleType)
{
return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL;
return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == nullptr;
}
int8_t DetectVehicleType(LegoPathActor* p_actor)

View File

@ -10,13 +10,13 @@ bool Extensions::Common::ResolveGamePath(const char* p_relativePath, MxString& p
{
p_outPath = MxString(MxOmni::GetHD()) + p_relativePath;
p_outPath.MapPathToFilesystem();
if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) {
if (SDL_GetPathInfo(p_outPath.GetData(), nullptr)) {
return true;
}
p_outPath = MxString(MxOmni::GetCD()) + p_relativePath;
p_outPath.MapPathToFilesystem();
if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) {
if (SDL_GetPathInfo(p_outPath.GetData(), nullptr)) {
return true;
}

View File

@ -1,5 +1,6 @@
#include "extensions/extensions.h"
#include "extensions/multiplayer.h"
#include "extensions/siloader.h"
#include "extensions/textureloader.h"
#include "extensions/thirdpersoncamera.h"
@ -29,9 +30,16 @@ static void InitThirdPersonCamera(std::map<std::string, std::string> p_options)
ThirdPersonCameraExt::Initialize();
}
static void InitMultiplayer(std::map<std::string, std::string> p_options)
{
MultiplayerExt::options = std::move(p_options);
MultiplayerExt::enabled = true;
MultiplayerExt::Initialize();
}
using InitFn = void (*)(std::map<std::string, std::string>);
static const InitFn extensionInits[] = {InitTextureLoader, InitSiLoader, InitThirdPersonCamera};
static const InitFn extensionInits[] = {InitTextureLoader, InitSiLoader, InitThirdPersonCamera, InitMultiplayer};
void Extensions::Enable(const char* p_key, std::map<std::string, std::string> p_options)
{

View File

@ -0,0 +1,306 @@
#include "extensions/multiplayer.h"
#include "extensions/common/charactercloner.h"
#include "extensions/common/charactercustomizer.h"
#include "extensions/common/constants.h"
#include "extensions/multiplayer/emoteanimhandler.h"
#include "extensions/multiplayer/networkmanager.h"
#include "extensions/multiplayer/networktransport.h"
#include "extensions/multiplayer/protocol.h"
#include "extensions/thirdpersoncamera.h"
#include "extensions/thirdpersoncamera/controller.h"
#include "isle_actions.h"
#include "legoactor.h"
#include "legoactors.h"
#include "legoentity.h"
#include "legoeventnotificationparam.h"
#include "legogamestate.h"
#include "legopathactor.h"
#include "misc.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/emscripten/callbacks.h"
#include "extensions/multiplayer/platforms/emscripten/websockettransport.h"
#include <emscripten.h>
#elif defined(ISLE_USE_LWS)
#include "extensions/multiplayer/platforms/native/lwstransport.h"
#include "extensions/multiplayer/platforms/native/nativecallbacks.h"
#endif
using namespace Extensions;
std::map<std::string, std::string> MultiplayerExt::options;
bool MultiplayerExt::enabled = false;
std::string MultiplayerExt::s_relayUrl;
std::string MultiplayerExt::s_room;
Multiplayer::NetworkManager* MultiplayerExt::s_networkManager = nullptr;
Multiplayer::NetworkTransport* MultiplayerExt::s_transport = nullptr;
Multiplayer::PlatformCallbacks* MultiplayerExt::s_callbacks = nullptr;
static Multiplayer::EmoteAnimHandler s_localEmoteHandler;
void MultiplayerExt::Initialize()
{
// Multiplayer depends on camera - ensure it's enabled
if (!ThirdPersonCameraExt::enabled) {
ThirdPersonCameraExt::enabled = true;
ThirdPersonCameraExt::Initialize();
}
s_relayUrl = options["multiplayer:relay url"];
s_room = options["multiplayer:room"];
#ifdef __EMSCRIPTEN__
s_transport = new Multiplayer::WebSocketTransport(s_relayUrl);
s_callbacks = new Multiplayer::EmscriptenCallbacks();
#elif defined(ISLE_USE_LWS)
s_transport = new Multiplayer::LwsTransport(s_relayUrl);
s_callbacks = new Multiplayer::NativeCallbacks();
#endif
#if defined(__EMSCRIPTEN__) || defined(ISLE_USE_LWS)
s_networkManager = new Multiplayer::NetworkManager();
s_networkManager->Initialize(s_transport, s_callbacks);
ThirdPersonCamera::Controller* cam = ThirdPersonCameraExt::GetCamera();
if (cam) {
cam->SetExtraAnimHandler(&s_localEmoteHandler);
cam->Enable();
}
std::string actor = options["multiplayer:actor"];
if (!actor.empty()) {
uint8_t displayIndex = Common::ResolveDisplayActorIndex(actor.c_str());
if (displayIndex != Common::DISPLAY_ACTOR_NONE && cam) {
cam->SetDisplayActorIndex(displayIndex);
cam->FreezeDisplayActor();
}
}
if (!s_relayUrl.empty() && !s_room.empty()) {
s_networkManager->Connect(s_room.c_str());
}
#endif
}
void MultiplayerExt::HandleCreate()
{
if (s_networkManager) {
s_networkManager->HandleCreate();
}
}
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;
}
MxBool MultiplayerExt::HandleROIClick(LegoROI* p_rootROI, LegoEventNotificationParam& p_param)
{
if (!s_networkManager) {
return FALSE;
}
Multiplayer::NetworkManager* mgr = s_networkManager;
// Check if it's a remote player
Multiplayer::RemotePlayer* remote = mgr->FindPlayerByROI(p_rootROI);
ThirdPersonCamera::Controller* cam = ThirdPersonCameraExt::GetCamera();
bool isSelf = (cam && cam->GetDisplayROI() != nullptr && cam->GetDisplayROI() == p_rootROI);
if (!remote && !isSelf) {
return FALSE;
}
// Remote player permission check
if (remote && !remote->GetAllowRemoteCustomize()) {
return TRUE; // Consume click, no effect
}
// Determine change type from clicker's actor ID
uint8_t changeType;
int partIndex;
if (!Common::CharacterCustomizer::ResolveClickChangeType(changeType, partIndex, p_param.GetROI())) {
return TRUE;
}
// Send a customize request to the server. The server echoes it back to all peers
// (including the sender). HandleCustomize then applies the change and plays effects.
// For remote targets this avoids flip-flop from stale state messages; for self targets
// it keeps the code path uniform.
uint32_t targetPeerId = remote ? remote->GetPeerId() : mgr->GetLocalPeerId();
mgr->SendCustomize(targetPeerId, changeType, static_cast<uint8_t>(partIndex >= 0 ? partIndex : 0xFF));
return TRUE;
}
MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
{
if (!s_networkManager) {
return FALSE;
}
// Suppress pizzeria clicks entirely in multiplayer
if (p_entity->IsA("Pizzeria") && s_networkManager->IsConnected()) {
return TRUE;
}
// Only intercept plants and buildings
MxU8 type = p_entity->GetType();
if (type != LegoEntity::e_plant && type != LegoEntity::e_building) {
return FALSE;
}
// Determine the change type based on the active character,
// mirroring the logic in LegoEntity::Notify().
MxU8 changeType;
switch (GameState()->GetActorId()) {
case LegoActor::c_pepper:
if (GameState()->GetCurrentAct() == LegoGameState::e_act2 ||
GameState()->GetCurrentAct() == LegoGameState::e_act3) {
return FALSE;
}
changeType = Multiplayer::CHANGE_VARIANT;
break;
case LegoActor::c_mama:
changeType = Multiplayer::CHANGE_SOUND;
break;
case LegoActor::c_papa:
changeType = Multiplayer::CHANGE_MOVE;
break;
case LegoActor::c_nick:
changeType = Multiplayer::CHANGE_COLOR;
break;
case LegoActor::c_laura:
changeType = Multiplayer::CHANGE_MOOD;
break;
case LegoActor::c_brickster:
changeType = Multiplayer::CHANGE_DECREMENT;
break;
default:
return FALSE;
}
return s_networkManager->HandleEntityMutation(p_entity, changeType);
}
MxBool MultiplayerExt::HandleSkyLightControl(MxU32 p_controlId)
{
if (!s_networkManager) {
return FALSE;
}
uint8_t entityType;
uint8_t changeType;
switch (p_controlId) {
case IsleScript::c_Observe_SkyColor_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_TOGGLE_COLOR;
break;
case IsleScript::c_Observe_Sun_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_DAY;
break;
case IsleScript::c_Observe_Moon_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_NIGHT;
break;
case IsleScript::c_Observe_GlobeRArrow_Ctl:
entityType = Multiplayer::ENTITY_LIGHT;
changeType = Multiplayer::LIGHT_INCREMENT;
break;
case IsleScript::c_Observe_GlobeLArrow_Ctl:
entityType = Multiplayer::ENTITY_LIGHT;
changeType = Multiplayer::LIGHT_DECREMENT;
break;
default:
return FALSE;
}
return s_networkManager->HandleSkyLightMutation(entityType, changeType);
}
void MultiplayerExt::HandleBeforeSaveLoad()
{
if (s_networkManager) {
s_networkManager->OnBeforeSaveLoad();
}
}
void MultiplayerExt::HandleSaveLoaded()
{
if (s_networkManager) {
s_networkManager->OnSaveLoaded();
}
}
MxBool MultiplayerExt::IsClonedCharacter(const char* p_name)
{
if (!s_networkManager) {
return FALSE;
}
return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE;
}
MxBool MultiplayerExt::CheckRejected()
{
if (s_networkManager && s_networkManager->WasRejected()) {
return TRUE;
}
return FALSE;
}
void MultiplayerExt::HandleHornPressed(MxU32 p_controlId)
{
if (!s_networkManager) {
return;
}
int8_t vehicleType;
switch (p_controlId) {
case IsleScript::c_BikeHorn_Ctl:
vehicleType = Multiplayer::VEHICLE_BIKE;
break;
case IsleScript::c_AmbulanceHorn_Ctl:
vehicleType = Multiplayer::VEHICLE_AMBULANCE;
break;
case IsleScript::c_TowHorn_Ctl:
vehicleType = Multiplayer::VEHICLE_TOWTRACK;
break;
case IsleScript::c_DuneCarHorn_Ctl:
vehicleType = Multiplayer::VEHICLE_DUNEBUGGY;
break;
default:
return;
}
s_networkManager->SendHorn(vehicleType);
}
Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager()
{
return s_networkManager;
}
bool Extensions::IsMultiplayerRejected()
{
return Extension<MultiplayerExt>::Call(MP::CheckRejected).value_or(FALSE);
}

View File

@ -0,0 +1,55 @@
#include "extensions/multiplayer/animation/audioplayer.h"
#include "extensions/multiplayer/animation/loader.h"
#include "legocachsound.h"
using namespace Multiplayer::Animation;
void AudioPlayer::Init(const std::vector<SceneAnimData::AudioTrack>& p_tracks)
{
for (const auto& audioTrack : p_tracks) {
LegoCacheSound* sound = new LegoCacheSound();
MxString mediaSrcPath(audioTrack.mediaSrcPath.c_str());
MxWavePresenter::WaveFormat format = audioTrack.format;
if (sound->Create(format, mediaSrcPath, audioTrack.volume, audioTrack.pcmData, audioTrack.pcmDataSize) ==
SUCCESS) {
// Disable Doppler on extension-created sounds. Camera animations drive high
// listener velocities via CalculateWorldVelocity, and miniaudio's default
// dopplerFactor of 1.0 shifts the pitch/speed of spatialized sounds.
ma_sound_set_doppler_factor(sound->m_cacheSound, 0);
ActiveSound active;
active.sound = sound;
active.timeOffset = audioTrack.timeOffset;
active.started = false;
m_activeSounds.push_back(active);
}
else {
delete sound;
}
}
}
void AudioPlayer::Tick(float p_elapsedMs, const char* p_roiName)
{
for (auto& active : m_activeSounds) {
if (!active.started && p_elapsedMs >= (float) active.timeOffset) {
active.sound->Play(p_roiName, FALSE);
active.started = true;
}
if (active.started) {
active.sound->FUN_10006be0();
}
}
}
void AudioPlayer::Cleanup()
{
for (auto& active : m_activeSounds) {
if (active.started) {
active.sound->Stop();
}
delete active.sound;
}
m_activeSounds.clear();
}

View File

@ -0,0 +1,634 @@
#include "extensions/multiplayer/animation/catalog.h"
#include "3dmanager/lego3dmanager.h"
#include "actions/isle_actions.h"
#include "decomp.h"
#include "extensions/common/pathutils.h"
#include "legoactors.h"
#include "legoanimationmanager.h"
#include "legomain.h"
#include "legomodelpresenter.h"
#include "legopartpresenter.h"
#include "legovideomanager.h"
#include "misc.h"
#include "misc/legostorage.h"
#include "modeldb/modeldb.h"
#include "mxdsaction.h"
#include "mxdschunk.h"
#include "roi/legoroi.h"
#include "viewmanager/viewlodlist.h"
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h>
using namespace Multiplayer::Animation;
// Defined in legoanimationmanager.cpp — not exported in headers.
extern LegoAnimationManager::Character g_characters[47];
extern LegoAnimationManager::Vehicle g_vehicles[7];
// Look up the g_vehicles[] index for a character's owned vehicle.
// p_actorInfoIndex is an index into g_actorInfoInit[].
// Returns -1 if the character has no vehicle.
static int8_t GetCharacterVehicleId(int8_t p_actorInfoIndex)
{
if (p_actorInfoIndex < 0 || p_actorInfoIndex >= (int8_t) SDL_min(sizeOfArray(g_actorInfoInit), (size_t) 64)) {
return -1;
}
const char* name = g_actorInfoInit[p_actorInfoIndex].m_name;
if (!name) {
return -1;
}
for (int i = 0; i < (int) sizeOfArray(g_characters); i++) {
if (!SDL_strcasecmp(name, g_characters[i].m_name)) {
return g_characters[i].m_vehicleId;
}
}
return -1;
}
// Exact-match a model name against g_actorInfoInit[].m_name.
// The engine's LegoAnimationManager::GetCharacterIndex uses 2-char prefix matching,
// which causes false positives (e.g. "ladder" matching "laura"). We need exact
// matching to correctly identify character performers vs props.
// Capped at 64 because performerMask is uint64_t.
static int8_t GetCharacterIndex(const char* p_name)
{
for (int8_t i = 0; i < (int8_t) SDL_min(sizeOfArray(g_actorInfoInit), (size_t) 64); i++) {
if (!SDL_strcasecmp(p_name, g_actorInfoInit[i].m_name)) {
return i;
}
}
return -1;
}
std::vector<int8_t> Multiplayer::Animation::GetPerformerIndices(uint64_t p_performerMask)
{
std::vector<int8_t> indices;
for (int8_t i = 0; i < 64; i++) {
if (p_performerMask & (uint64_t(1) << i)) {
indices.push_back(i);
}
}
return indices;
}
uint8_t Catalog::WorldIdToSlot(int8_t p_worldId)
{
switch (p_worldId) {
case LegoOmni::e_act1:
return WORLD_SLOT_ACT1;
case LegoOmni::e_act2:
return WORLD_SLOT_ACT2;
case LegoOmni::e_act3:
return WORLD_SLOT_ACT3;
default:
return 0xFF;
}
}
Catalog::~Catalog()
{
Cleanup();
}
void Catalog::Cleanup()
{
m_entries.clear();
m_locationIndex.clear();
for (auto& wd : m_worldData) {
FreeAnimInfo(wd.anims, wd.animCount);
}
m_worldData.clear();
for (auto* roi : m_modelROIs) {
VideoManager()->Get3DManager()->Remove(*roi);
delete roi;
}
m_modelROIs.clear();
}
void Catalog::FreeAnimInfo(AnimInfo* p_anims, uint16_t p_count)
{
if (!p_anims) {
return;
}
for (uint16_t i = 0; i < p_count; i++) {
delete[] p_anims[i].m_name;
if (p_anims[i].m_models) {
for (uint8_t j = 0; j < p_anims[i].m_modelCount; j++) {
delete[] p_anims[i].m_models[j].m_name;
}
delete[] p_anims[i].m_models;
}
}
delete[] p_anims;
}
bool Catalog::ParseDTAFile(int8_t p_worldId, AnimInfo*& p_outAnims, uint16_t& p_outCount)
{
p_outAnims = nullptr;
p_outCount = 0;
const char* worldName = Lego()->GetWorldName((LegoOmni::World) p_worldId);
if (!worldName) {
return false;
}
char relativePath[128];
SDL_snprintf(relativePath, sizeof(relativePath), "\\lego\\data\\%sinf.dta", worldName);
MxString path;
if (!Extensions::Common::ResolveGamePath(relativePath, path)) {
return false;
}
LegoFile storage;
if (storage.Open(path.GetData(), LegoStorage::c_read) != SUCCESS) {
return false;
}
MxU32 version;
if (storage.Read(&version, sizeof(MxU32)) != SUCCESS) {
return false;
}
if (version != 3) {
SDL_Log("DTA version mismatch for world %s: expected 3, got %u", worldName, version);
return false;
}
MxU16 animCount;
if (storage.Read(&animCount, sizeof(MxU16)) != SUCCESS) {
return false;
}
if (animCount == 0) {
return false;
}
AnimInfo* anims = new AnimInfo[animCount];
SDL_memset(anims, 0, animCount * sizeof(AnimInfo));
for (uint16_t i = 0; i < animCount; i++) {
if (AnimationManager()->ReadAnimInfo(&storage, &anims[i]) != SUCCESS) {
goto fail;
}
// Compute derived fields (mirrors LoadWorldInfo logic)
anims[i].m_characterIndex = -1;
anims[i].m_unk0x29 = FALSE;
for (int k = 0; k < 3; k++) {
anims[i].m_unk0x2a[k] = -1;
}
// Compute vehicle indices from model names
int vehicleCount = 0;
for (uint8_t m = 0; m < anims[i].m_modelCount && vehicleCount < 3; m++) {
MxU32 vehicleIdx;
if (AnimationManager()->FindVehicle(anims[i].m_models[m].m_name, vehicleIdx) &&
anims[i].m_models[m].m_unk0x2c) {
anims[i].m_unk0x2a[vehicleCount++] = (MxS8) vehicleIdx;
}
}
}
p_outAnims = anims;
p_outCount = animCount;
return true;
fail:
FreeAnimInfo(anims, animCount);
return false;
}
void Catalog::BuildEntries(const WorldAnimData& p_world)
{
if (!p_world.anims || p_world.animCount == 0) {
return;
}
for (uint16_t i = 0; i < p_world.animCount; i++) {
const AnimInfo& animInfo = p_world.anims[i];
if (!animInfo.m_name || animInfo.m_objectId == 0) {
continue;
}
CatalogEntry entry;
entry.animIndex = WorldAnimIndex(p_world.worldSlot, i);
entry.worldId = p_world.worldId;
entry.spectatorMask = animInfo.m_unk0x0c;
entry.location = animInfo.m_location;
entry.modelCount = animInfo.m_modelCount;
// Compute performerMask by matching models against g_actorInfoInit[].m_name
entry.performerMask = 0;
for (uint8_t m = 0; m < entry.modelCount; m++) {
if (animInfo.m_models && animInfo.m_models[m].m_name) {
int8_t charIdx = GetCharacterIndex(animInfo.m_models[m].m_name);
if (charIdx >= 0) {
entry.performerMask |= (uint64_t(1) << charIdx);
}
}
}
// Compute vehicleMask from the pre-populated vehicle list (m_unk0x2a).
entry.vehicleMask = 0;
for (int k = 0; k < 3; k++) {
if (animInfo.m_unk0x2a[k] >= 0 && animInfo.m_unk0x2a[k] < (int8_t) sizeOfArray(g_vehicles)) {
entry.vehicleMask |= (1 << animInfo.m_unk0x2a[k]);
}
}
// Categorize based on whether the animation has named character performers.
// g_actorInfoInit layout:
// 0-47: named characters (pepper through jk)
// 48-53: ghosts
// 54-57: named characters (hg, pntgy, pep, cop01)
// 58-65: generic extras (actor_01-05, vehicle riders)
static const uint64_t NAMED_CHARACTER_MASK = ((uint64_t(1) << 48) - 1) | (uint64_t(0xF) << 54);
bool hasNamedPerformer = (entry.performerMask & NAMED_CHARACTER_MASK) != 0;
// Manual overrides for prop-only animations that have no character
// performers but are valid scene animations with spectator-only slots.
// These are Isle-specific (ACT1) object IDs.
bool overridden = false;
if (p_world.worldId == LegoOmni::e_act1) {
MxU32 objectId = animInfo.m_objectId;
if (objectId == IsleScript::c_snsx31sh_RunAnim || objectId == IsleScript::c_fpz166p1_RunAnim ||
objectId == IsleScript::c_nic002pr_RunAnim || objectId == IsleScript::c_nic003pr_RunAnim ||
objectId == IsleScript::c_nic004pr_RunAnim || objectId == IsleScript::c_prp101pr_RunAnim) {
if (objectId == IsleScript::c_prp101pr_RunAnim) {
entry.location = 11; // Hospital
}
entry.category = e_camAnim;
overridden = true;
}
}
if (!overridden) {
if (!hasNamedPerformer) {
entry.category = e_otherAnim;
}
else if (entry.location == -1) {
entry.category = e_npcAnim;
}
else {
entry.category = e_camAnim;
}
}
size_t idx = m_entries.size();
m_entries.push_back(entry);
// Build location index
m_locationIndex[entry.location].push_back(idx);
}
}
void Catalog::Refresh()
{
Cleanup();
static const int8_t worldIds[] = {
(int8_t) LegoOmni::e_act1,
(int8_t) LegoOmni::e_act2,
(int8_t) LegoOmni::e_act3,
};
for (int w = 0; w < (int) sizeOfArray(worldIds); w++) {
int8_t worldId = worldIds[w];
uint8_t slot = WorldIdToSlot(worldId);
if (slot == 0xFF) {
continue;
}
AnimInfo* anims = nullptr;
uint16_t count = 0;
if (!ParseDTAFile(worldId, anims, count)) {
continue;
}
WorldAnimData wd;
wd.worldId = worldId;
wd.worldSlot = slot;
wd.anims = anims;
wd.animCount = count;
m_worldData.push_back(wd);
BuildEntries(wd);
}
LoadWorldParts();
}
const AnimInfo* Catalog::GetAnimInfo(uint16_t p_animIndex) const
{
uint8_t slot = GetWorldSlot(p_animIndex);
uint16_t localIndex = GetLocalIndex(p_animIndex);
for (const auto& wd : m_worldData) {
if (wd.worldSlot == slot) {
if (localIndex < wd.animCount) {
return &wd.anims[localIndex];
}
return nullptr;
}
}
return nullptr;
}
int8_t Catalog::DisplayActorToCharacterIndex(uint8_t p_displayActorIndex)
{
if (p_displayActorIndex >= SDL_min(sizeOfArray(g_actorInfoInit), (size_t) 64)) {
return -1;
}
return static_cast<int8_t>(p_displayActorIndex);
}
const CatalogEntry* Catalog::FindEntry(uint16_t p_animIndex) const
{
for (const auto& entry : m_entries) {
if (entry.animIndex == p_animIndex) {
return &entry;
}
}
return nullptr;
}
std::vector<const CatalogEntry*> Catalog::GetAnimationsAtLocation(int16_t p_location) const
{
std::vector<const CatalogEntry*> result;
// Helper to add entries from a location, filtering out e_otherAnim
auto addFromLocation = [&](int16_t loc) {
auto it = m_locationIndex.find(loc);
if (it != m_locationIndex.end()) {
for (size_t idx : it->second) {
if (m_entries[idx].category != e_otherAnim) {
result.push_back(&m_entries[idx]);
}
}
}
};
// Always include NPC animations (location == -1)
addFromLocation(-1);
// If requesting a specific location, also include location-bound animations
if (p_location >= 0) {
addFromLocation(p_location);
}
return result;
}
bool Catalog::CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex)
{
if (p_charIndex < CORE_CHARACTER_COUNT) {
return (p_entry->spectatorMask >> p_charIndex) & 1;
}
// Non-core characters (index 5+): only if all core actors allowed
return p_entry->spectatorMask == ALL_CORE_ACTORS_MASK;
}
bool Catalog::CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState)
{
int8_t vehicleId = GetCharacterVehicleId(p_charIndex);
if (vehicleId < 0) {
return true; // Character has no vehicle — no constraint (Mama, Papa, NPCs)
}
bool animUsesVehicle = (p_entry->vehicleMask >> vehicleId) & 1;
switch (p_vehicleState) {
case e_onOwnVehicle:
return animUsesVehicle; // Only animations that use this character's vehicle
case e_onOtherVehicle:
return false; // On a foreign vehicle — no animations eligible
default: // e_onFoot
return !animUsesVehicle; // Only animations that don't use this character's vehicle
}
}
int8_t Catalog::GetVehicleCategory(int8_t p_vehicleIdx)
{
if (p_vehicleIdx >= 0 && p_vehicleIdx <= 3) {
return 0; // bike (bikebd, bikepg, bikerd, bikesy)
}
if (p_vehicleIdx >= 4 && p_vehicleIdx <= 5) {
return 1; // motorcycle (motoni, motola)
}
if (p_vehicleIdx == 6) {
return 2; // skateboard (board)
}
return -1;
}
Catalog::VehicleState Catalog::GetVehicleState(int8_t p_charIndex, LegoROI* p_vehicleROI)
{
if (!p_vehicleROI || !p_vehicleROI->GetName()) {
return e_onFoot;
}
int8_t charVehicleId = GetCharacterVehicleId(p_charIndex);
if (charVehicleId < 0) {
return e_onFoot; // Character has no vehicle — treat any ride as irrelevant
}
MxU32 rideVehicleIdx;
if (!AnimationManager()->FindVehicle(p_vehicleROI->GetName(), rideVehicleIdx)) {
return e_onOtherVehicle; // Unknown vehicle — treat as foreign
}
// Compare by category — the ride system uses representative names (bikebd/motoni/board)
// that may differ from the character's specific vehicle index but share the same category.
if (GetVehicleCategory((int8_t) rideVehicleIdx) == GetVehicleCategory(charVehicleId)) {
return e_onOwnVehicle;
}
return e_onOtherVehicle;
}
bool Catalog::CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex)
{
if (p_charIndex < 0) {
return false;
}
// Performer: player's character is one of the performing models
if ((p_entry->performerMask >> p_charIndex) & 1) {
return true;
}
// Spectator: not a performer, spectator mask allows them
return CheckSpectatorMask(p_entry, p_charIndex);
}
bool Catalog::CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActorIndex) const
{
return CanParticipateChar(p_entry, DisplayActorToCharacterIndex(p_displayActorIndex));
}
bool Catalog::CanTrigger(
const CatalogEntry* p_entry,
const int8_t* p_charIndices,
const uint8_t* p_onVehicle,
uint8_t p_count,
uint64_t* p_filledPerformers,
bool* p_spectatorFilled
) const
{
*p_filledPerformers = 0;
*p_spectatorFilled = false;
// First pass: assign performers (each performer slot needs exactly one player)
std::vector<bool> assignedAsPerformer(p_count, false);
for (uint8_t i = 0; i < p_count; i++) {
int8_t charIndex = p_charIndices[i];
if (charIndex < 0) {
continue;
}
uint64_t charBit = uint64_t(1) << charIndex;
if ((p_entry->performerMask & charBit) && !(*p_filledPerformers & charBit)) {
if (p_onVehicle && !CheckVehicleEligibility(p_entry, charIndex, p_onVehicle[i])) {
continue;
}
*p_filledPerformers |= charBit;
assignedAsPerformer[i] = true;
}
}
bool allPerformersCovered = (*p_filledPerformers == p_entry->performerMask);
// Second pass: find a spectator among unassigned players
for (uint8_t i = 0; i < p_count; i++) {
if (assignedAsPerformer[i]) {
continue;
}
int8_t charIndex = p_charIndices[i];
if (charIndex >= 0 && !((p_entry->performerMask >> charIndex) & 1) && CheckSpectatorMask(p_entry, charIndex)) {
*p_spectatorFilled = true;
break;
}
}
return allPerformersCovered && *p_spectatorFilled;
}
void Catalog::LoadWorldParts()
{
MxString wdbPath;
if (!Extensions::Common::ResolveGamePath("\\lego\\data\\world.wdb", wdbPath)) {
return;
}
SDL_IOStream* wdbFile = SDL_IOFromFile(wdbPath.GetData(), "rb");
if (!wdbFile) {
return;
}
ModelDbWorld* worlds = nullptr;
MxS32 numWorlds = 0;
ReadModelDbWorlds(wdbFile, worlds, numWorlds);
if (!worlds || numWorlds == 0) {
SDL_CloseIO(wdbFile);
return;
}
// Skip the global textures + parts section (same offset cached by the game)
// We need to read it if the game hasn't already (g_wdbSkipGlobalPartsOffset == 0),
// but the game always loads before us, so just skip past it.
// The game's LoadWorld() sets g_wdbSkipGlobalPartsOffset after reading globals.
for (MxS32 i = 0; i < numWorlds; i++) {
// Load parts from all worlds (skip check: Lookup returns non-null if already registered)
ModelDbPartListCursor cursor(worlds[i].m_partList);
ModelDbPart* part;
while (cursor.Next(part)) {
ViewLODList* existing = GetViewLODListManager()->Lookup(part->m_roiName.GetData());
if (existing) {
existing->Release();
continue;
}
MxU8* buff = new MxU8[part->m_partDataLength];
SDL_SeekIO(wdbFile, part->m_partDataOffset, SDL_IO_SEEK_SET);
if (SDL_ReadIO(wdbFile, buff, part->m_partDataLength) != part->m_partDataLength) {
delete[] buff;
continue;
}
MxDSChunk chunk;
chunk.SetLength(part->m_partDataLength);
chunk.SetData(buff);
LegoPartPresenter partPresenter;
if (partPresenter.Read(chunk) == SUCCESS) {
partPresenter.Store();
}
delete[] buff;
}
// Load models whose LODs aren't registered yet
for (MxS32 j = 0; j < worlds[i].m_numModels; j++) {
ModelDbModel& model = worlds[i].m_models[j];
if (!model.m_modelName) {
continue;
}
// Only load models that aren't already available as LODs
char loweredName[256];
SDL_strlcpy(loweredName, model.m_modelName, sizeof(loweredName));
SDL_strlwr(loweredName);
ViewLODList* existing = GetViewLODListManager()->Lookup(loweredName);
if (existing) {
existing->Release();
continue;
}
MxU8* buff = new MxU8[model.m_modelDataLength];
SDL_SeekIO(wdbFile, model.m_modelDataOffset, SDL_IO_SEEK_SET);
if (SDL_ReadIO(wdbFile, buff, model.m_modelDataLength) != model.m_modelDataLength) {
delete[] buff;
continue;
}
MxDSChunk chunk;
chunk.SetLength(model.m_modelDataLength);
chunk.SetData(buff);
// Use friend access to LegoModelPresenter's private CreateROI + m_roi
LegoModelPresenter modelPresenter;
MxDSAction action;
modelPresenter.SetAction(&action);
if (modelPresenter.CreateROI(&chunk) == SUCCESS && modelPresenter.m_roi) {
// Add to 3D scene (hidden) so ScenePlayer::cloneSceneROI can find it
modelPresenter.m_roi->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Add(*modelPresenter.m_roi);
// Steal the ROI to keep it alive (Destroy() just nulls m_roi)
m_modelROIs.push_back(modelPresenter.m_roi);
modelPresenter.m_roi = nullptr;
}
delete[] buff;
}
}
FreeModelDbWorlds(worlds, numWorlds);
SDL_CloseIO(wdbFile);
}

View File

@ -0,0 +1,304 @@
#include "extensions/multiplayer/animation/coordinator.h"
#include "extensions/multiplayer/animation/catalog.h"
#include "legoactors.h"
#include <SDL3/SDL_timer.h>
using namespace Multiplayer::Animation;
Coordinator::Coordinator()
: m_catalog(nullptr), m_state(CoordinationState::e_idle), m_currentAnimIndex(ANIM_INDEX_NONE), m_localPeerId(0),
m_cancelPending(false)
{
}
void Coordinator::SetCatalog(const Catalog* p_catalog)
{
m_catalog = p_catalog;
}
void Coordinator::SetLocalPeerId(uint32_t p_localPeerId)
{
m_localPeerId = p_localPeerId;
}
void Coordinator::SetInterest(uint16_t p_currentAnimIndex)
{
if (m_state != CoordinationState::e_idle && m_state != CoordinationState::e_interested) {
return;
}
m_currentAnimIndex = p_currentAnimIndex;
m_state = CoordinationState::e_interested;
m_cancelPending = false;
}
void Coordinator::ClearInterest()
{
if (m_state == CoordinationState::e_interested || m_state == CoordinationState::e_countdown ||
m_state == CoordinationState::e_playing) {
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
m_cancelPending = true;
}
}
// Build the unified slots vector from CanTrigger results.
// Each bit in performerMask becomes one slot; the spectator becomes one slot at the end.
static void BuildSlots(
const CatalogEntry* p_entry,
uint64_t p_filledPerformers,
bool p_spectatorFilled,
std::vector<SlotInfo>& p_slots
)
{
// One slot per performer bit in performerMask
for (int8_t i : GetPerformerIndices(p_entry->performerMask)) {
SlotInfo slot;
if (i < (int8_t) sizeOfArray(g_actorInfoInit)) {
slot.names.push_back(g_actorInfoInit[i].m_name);
}
slot.filled = (p_filledPerformers & (uint64_t(1) << i)) != 0;
p_slots.push_back(std::move(slot));
}
// One spectator slot
SlotInfo spectatorSlot;
if (p_entry->spectatorMask == ALL_CORE_ACTORS_MASK) {
spectatorSlot.names.push_back("any");
}
else {
for (int8_t i = 0; i < CORE_CHARACTER_COUNT; i++) {
if ((p_entry->spectatorMask >> i) & 1) {
spectatorSlot.names.push_back(g_actorInfoInit[i].m_name);
}
}
}
spectatorSlot.filled = p_spectatorFilled;
p_slots.push_back(std::move(spectatorSlot));
}
std::vector<EligibilityInfo> Coordinator::ComputeEligibility(
int16_t p_location,
const int8_t* p_locationChars,
const uint8_t* p_locationVehicles,
uint8_t p_locationCount,
const int8_t* p_proximityChars,
const uint8_t* p_proximityVehicles,
uint8_t p_proximityCount
) const
{
std::vector<EligibilityInfo> result;
if (!m_catalog || p_locationCount == 0) {
return result;
}
auto anims = m_catalog->GetAnimationsAtLocation(p_location);
for (const CatalogEntry* entry : anims) {
// p_locationChars[0] == p_proximityChars[0] == local player
if (!Catalog::CanParticipateChar(entry, p_locationChars[0])) {
continue;
}
// Vehicle eligibility: only filter if the local player would be a performer.
// Spectator-only roles remain visible so players on vehicles can still watch nearby scenes.
if ((entry->performerMask >> p_locationChars[0]) & 1) {
if (!Catalog::CheckVehicleEligibility(entry, p_locationChars[0], p_locationVehicles[0])) {
continue;
}
}
// NPC anims (location == -1): use proximity characters
// Cam anims (location >= 0): use location characters
const int8_t* chars = (entry->location == -1) ? p_proximityChars : p_locationChars;
const uint8_t* vehicles = (entry->location == -1) ? p_proximityVehicles : p_locationVehicles;
uint8_t count = (entry->location == -1) ? p_proximityCount : p_locationCount;
EligibilityInfo info;
info.animIndex = entry->animIndex;
info.entry = entry;
bool atLoc = (entry->location == -1) || (entry->location == p_location);
info.atLocation = atLoc;
uint64_t filledPerformers = 0;
bool spectatorFilled = false;
if (atLoc) {
info.eligible = m_catalog->CanTrigger(entry, chars, vehicles, count, &filledPerformers, &spectatorFilled);
}
else {
info.eligible = false;
}
BuildSlots(entry, filledPerformers, spectatorFilled, info.slots);
// Override slot fills with authoritative session data
auto sessionIt = m_sessions.find(entry->animIndex);
if (sessionIt != m_sessions.end()) {
const SessionView& sv = sessionIt->second;
uint8_t slotCount =
sv.slotCount < info.slots.size() ? sv.slotCount : static_cast<uint8_t>(info.slots.size());
for (uint8_t s = 0; s < slotCount; s++) {
info.slots[s].filled = (sv.peerSlots[s] != 0);
}
}
result.push_back(std::move(info));
}
return result;
}
void Coordinator::OnLocationChanged(const std::vector<int16_t>& p_locations, const Catalog* p_catalog)
{
if (m_state != CoordinationState::e_interested || !p_catalog) {
return;
}
// Check if the currently interested animation is still available at any of the locations
for (int16_t loc : p_locations) {
auto anims = p_catalog->GetAnimationsAtLocation(loc);
for (const auto* e : anims) {
if (e->animIndex == m_currentAnimIndex) {
return; // still available at this location
}
}
}
// Also check NPC anims when at no location
if (p_locations.empty()) {
auto anims = p_catalog->GetAnimationsAtLocation(-1);
for (const auto* e : anims) {
if (e->animIndex == m_currentAnimIndex) {
return;
}
}
}
// Animation not at any current location — clear interest
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
m_cancelPending = true;
}
void Coordinator::Reset()
{
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
m_sessions.clear();
m_cancelPending = false;
}
void Coordinator::ResetLocalState()
{
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
m_cancelPending = false;
}
void Coordinator::RemoveSession(uint16_t p_animIndex)
{
m_sessions.erase(p_animIndex);
}
void Coordinator::ApplySessionUpdate(
uint16_t p_currentAnimIndex,
uint8_t p_state,
uint16_t p_countdownMs,
const uint32_t p_slots[8],
uint8_t p_slotCount
)
{
if (p_state == 0) {
// Session cleared
m_sessions.erase(p_currentAnimIndex);
// If local player was in this session, reset to idle
if (m_currentAnimIndex == p_currentAnimIndex &&
(m_state == CoordinationState::e_interested || m_state == CoordinationState::e_countdown ||
m_state == CoordinationState::e_playing)) {
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
}
return;
}
SessionView& sv = m_sessions[p_currentAnimIndex];
sv.state = static_cast<CoordinationState>(p_state);
sv.countdownMs = p_countdownMs;
sv.countdownEndTime = (p_countdownMs > 0) ? (SDL_GetTicks() + p_countdownMs) : 0;
sv.slotCount = p_slotCount < 8 ? p_slotCount : 8;
for (uint8_t i = 0; i < 8; i++) {
sv.peerSlots[i] = (i < sv.slotCount) ? p_slots[i] : 0;
}
// If local player is in this session, update coordinator state
if (m_localPeerId != 0) {
bool localInSession = false;
for (uint8_t i = 0; i < sv.slotCount; i++) {
if (sv.peerSlots[i] == m_localPeerId) {
localInSession = true;
break;
}
}
if (localInSession && !m_cancelPending) {
m_currentAnimIndex = p_currentAnimIndex;
m_state = sv.state;
}
else if (!localInSession) {
if (m_currentAnimIndex == p_currentAnimIndex) {
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
}
m_cancelPending = false;
}
}
}
void Coordinator::ApplyAnimStart(uint16_t p_currentAnimIndex)
{
if (IsLocalPlayerInSession(p_currentAnimIndex)) {
m_state = CoordinationState::e_playing;
m_currentAnimIndex = p_currentAnimIndex;
}
// Update session view so PushAnimationState reads correct values
auto it = m_sessions.find(p_currentAnimIndex);
if (it != m_sessions.end()) {
it->second.state = CoordinationState::e_playing;
it->second.countdownMs = 0;
}
}
const SessionView* Coordinator::GetSessionView(uint16_t p_animIndex) const
{
auto it = m_sessions.find(p_animIndex);
if (it != m_sessions.end()) {
return &it->second;
}
return nullptr;
}
bool Coordinator::IsLocalPlayerInSession(uint16_t p_animIndex) const
{
if (m_cancelPending || m_localPeerId == 0) {
return false;
}
auto it = m_sessions.find(p_animIndex);
if (it == m_sessions.end()) {
return false;
}
for (uint8_t i = 0; i < it->second.slotCount; i++) {
if (it->second.peerSlots[i] == m_localPeerId) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,441 @@
#include "extensions/multiplayer/animation/loader.h"
#include "anim/legoanim.h"
#include "flic.h"
#include "legomain.h"
#include "misc/legostorage.h"
#include "mxautolock.h"
#include <SDL3/SDL_stdinc.h>
#include <interleaf.h>
using namespace Multiplayer::Animation;
static void ParseExtraDirectives(const si::bytearray& p_extra, SceneAnimData& p_data)
{
if (p_extra.empty()) {
return;
}
std::string extra(p_extra.data(), p_extra.size());
while (!extra.empty() && extra.back() == '\0') {
extra.pop_back();
}
if (extra.find("HIDE_ON_STOP") != std::string::npos) {
p_data.hideOnStop = true;
}
size_t pos = extra.find("PTATCAM");
if (pos != std::string::npos) {
pos += 7;
// Skip the key-value separator (colon, comma, space, etc. — same set as KeyValueStringParse)
if (pos < extra.size() &&
(extra[pos] == ':' || extra[pos] == ',' || extra[pos] == ' ' || extra[pos] == '\t' || extra[pos] == '=')) {
pos++;
}
size_t end = extra.find(' ', pos);
std::string value = (end != std::string::npos) ? extra.substr(pos, end - pos) : extra.substr(pos);
size_t start = 0;
while (start < value.size()) {
size_t delim = value.find_first_of(":;", start);
std::string token = (delim != std::string::npos) ? value.substr(start, delim - start) : value.substr(start);
if (!token.empty()) {
p_data.ptAtCamNames.push_back(token);
}
start = (delim != std::string::npos) ? delim + 1 : value.size();
}
}
}
SceneAnimData::SceneAnimData() : anim(nullptr), duration(0.0f), actionTransform{}, hideOnStop(false)
{
}
SceneAnimData::~SceneAnimData()
{
delete anim;
ReleaseTracks();
}
void SceneAnimData::ReleaseTracks()
{
for (auto& track : audioTracks) {
delete[] track.pcmData;
}
for (auto& track : phonemeTracks) {
delete[] reinterpret_cast<MxU8*>(track.flcHeader);
}
}
SceneAnimData::SceneAnimData(SceneAnimData&& p_other) noexcept
: anim(p_other.anim), duration(p_other.duration), audioTracks(std::move(p_other.audioTracks)),
phonemeTracks(std::move(p_other.phonemeTracks)), actionTransform(p_other.actionTransform),
ptAtCamNames(std::move(p_other.ptAtCamNames)), hideOnStop(p_other.hideOnStop)
{
p_other.anim = nullptr;
}
SceneAnimData& SceneAnimData::operator=(SceneAnimData&& p_other) noexcept
{
if (this != &p_other) {
delete anim;
ReleaseTracks();
anim = p_other.anim;
duration = p_other.duration;
audioTracks = std::move(p_other.audioTracks);
phonemeTracks = std::move(p_other.phonemeTracks);
actionTransform = p_other.actionTransform;
ptAtCamNames = std::move(p_other.ptAtCamNames);
hideOnStop = p_other.hideOnStop;
p_other.anim = nullptr;
}
return *this;
}
Loader::Loader()
: m_reader(nullptr), m_preloadThread(nullptr), m_preloadWorldId(0), m_preloadObjectId(0), m_preloadDone(false)
{
}
Loader::~Loader()
{
CleanupPreloadThread();
for (auto& pair : m_extraSI) {
delete pair.second.interleaf;
delete pair.second.file;
}
}
const char* Loader::GetSIPath(int8_t p_worldId)
{
switch (p_worldId) {
case LegoOmni::e_act1:
return "\\lego\\scripts\\isle\\isle.si";
case LegoOmni::e_act2:
return "\\lego\\scripts\\act2\\act2main.si";
case LegoOmni::e_act3:
return "\\lego\\scripts\\act3\\act3.si";
default:
return nullptr;
}
}
bool Loader::OpenWorldSI(int8_t p_worldId)
{
// Act1 uses the external SIReader
if (p_worldId == LegoOmni::e_act1) {
return m_reader && m_reader->Open();
}
auto it = m_extraSI.find(p_worldId);
if (it != m_extraSI.end() && it->second.ready) {
return true;
}
const char* siPath = GetSIPath(p_worldId);
if (!siPath) {
return false;
}
SIHandle handle = {nullptr, nullptr, false};
if (!SIReader::OpenHeaderOnly(siPath, handle.file, handle.interleaf)) {
return false;
}
handle.ready = true;
m_extraSI[p_worldId] = handle;
return true;
}
bool Loader::ReadWorldObject(int8_t p_worldId, uint32_t p_objectId, si::Object*& p_outObj)
{
p_outObj = nullptr;
if (p_worldId == LegoOmni::e_act1) {
// Act1: use external SIReader
if (!m_reader || !m_reader->ReadObject(p_objectId)) {
return false;
}
p_outObj = m_reader->GetObject(p_objectId);
return p_outObj != nullptr;
}
auto it = m_extraSI.find(p_worldId);
if (it == m_extraSI.end() || !it->second.ready) {
return false;
}
si::Interleaf* interleaf = it->second.interleaf;
si::File* file = it->second.file;
size_t childCount = interleaf->GetChildCount();
if (p_objectId >= childCount) {
return false;
}
si::Object* obj = static_cast<si::Object*>(interleaf->GetChildAt(p_objectId));
if (obj->type() == si::MxOb::Null) {
if (interleaf->ReadObject(file, p_objectId) != si::Interleaf::ERROR_SUCCESS) {
return false;
}
obj = static_cast<si::Object*>(interleaf->GetChildAt(p_objectId));
}
p_outObj = obj;
return true;
}
bool Loader::ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data)
{
auto& chunks = p_child->data_;
if (chunks.empty()) {
return false;
}
auto& firstChunk = chunks[0];
if (firstChunk.size() < 7 * sizeof(MxS32)) {
return false;
}
LegoMemory storage(firstChunk.data(), (LegoU32) firstChunk.size());
MxS32 magicSig;
if (storage.Read(&magicSig, sizeof(MxS32)) != SUCCESS || magicSig != 0x11) {
return false;
}
// Skip boundingRadius + centerPoint[3] (unused, but present in the binary format)
LegoU32 pos;
storage.GetPosition(pos);
storage.SetPosition(pos + 4 * sizeof(float));
LegoS32 parseScene = 0;
MxS32 val3;
if (storage.Read(&parseScene, sizeof(LegoS32)) != SUCCESS) {
return false;
}
if (storage.Read(&val3, sizeof(MxS32)) != SUCCESS) {
return false;
}
p_data.anim = new LegoAnim();
if (p_data.anim->Read(&storage, parseScene) != SUCCESS) {
delete p_data.anim;
p_data.anim = nullptr;
return false;
}
p_data.duration = (float) p_data.anim->GetDuration();
return true;
}
bool Loader::ParsePhonemeChild(si::Object* p_child, SceneAnimData& p_data)
{
auto& chunks = p_child->data_;
if (chunks.size() < 2) {
return false;
}
SceneAnimData::PhonemeTrack track;
const auto& headerChunk = chunks[0];
if (headerChunk.size() < sizeof(FLIC_HEADER)) {
return false;
}
MxU8* headerBuf = new MxU8[headerChunk.size()];
SDL_memcpy(headerBuf, headerChunk.data(), headerChunk.size());
track.flcHeader = reinterpret_cast<FLIC_HEADER*>(headerBuf);
track.width = track.flcHeader->width;
track.height = track.flcHeader->height;
for (size_t i = 1; i < chunks.size(); i++) {
track.frameData.push_back(chunks[i]);
}
if (!p_child->extra_.empty()) {
track.roiName = std::string(p_child->extra_.data(), p_child->extra_.size());
while (!track.roiName.empty() && track.roiName.back() == '\0') {
track.roiName.pop_back();
}
}
track.timeOffset = p_child->time_offset_;
p_data.phonemeTracks.push_back(std::move(track));
return true;
}
bool Loader::ParseComposite(si::Object* p_composite, SceneAnimData& p_data)
{
bool hasAnim = false;
for (size_t i = 0; i < p_composite->GetChildCount(); i++) {
si::Object* child = static_cast<si::Object*>(p_composite->GetChildAt(i));
if (child->presenter_.find("LegoPhonemePresenter") != std::string::npos) {
ParsePhonemeChild(child, p_data);
}
else if (child->presenter_.find("LegoAnimPresenter") != std::string::npos || child->presenter_.find("LegoLoopingAnimPresenter") != std::string::npos) {
if (!hasAnim) {
if (ParseAnimationChild(child, p_data)) {
hasAnim = true;
ParseExtraDirectives(child->extra_, p_data);
// Extract action transform. Try child first, fall back to composite if zero.
si::Object* source = child;
if (SDL_fabs(child->direction_.x) < 1e-7 && SDL_fabs(child->direction_.y) < 1e-7 &&
SDL_fabs(child->direction_.z) < 1e-7) {
source = p_composite;
}
p_data.actionTransform.location[0] = (float) source->location_.x;
p_data.actionTransform.location[1] = (float) source->location_.y;
p_data.actionTransform.location[2] = (float) source->location_.z;
p_data.actionTransform.direction[0] = (float) source->direction_.x;
p_data.actionTransform.direction[1] = (float) source->direction_.y;
p_data.actionTransform.direction[2] = (float) source->direction_.z;
p_data.actionTransform.up[0] = (float) source->up_.x;
p_data.actionTransform.up[1] = (float) source->up_.y;
p_data.actionTransform.up[2] = (float) source->up_.z;
p_data.actionTransform.valid =
(SDL_fabsf(p_data.actionTransform.direction[0]) >= 0.00000047683716f ||
SDL_fabsf(p_data.actionTransform.direction[1]) >= 0.00000047683716f ||
SDL_fabsf(p_data.actionTransform.direction[2]) >= 0.00000047683716f);
}
}
}
else if (child->filetype() == si::MxOb::WAV) {
Multiplayer::AudioTrack track;
if (SIReader::ExtractAudioTrack(child, track)) {
p_data.audioTracks.push_back(std::move(track));
}
}
}
return hasAnim;
}
SceneAnimData* Loader::EnsureCached(int8_t p_worldId, uint32_t p_objectId)
{
uint64_t key = CacheKey(p_worldId, p_objectId);
{
AUTOLOCK(m_cacheCS);
auto it = m_cache.find(key);
if (it != m_cache.end()) {
return &it->second;
}
}
// If a preload is in progress for this object, wait for it to finish
if (m_preloadThread && m_preloadWorldId == p_worldId && m_preloadObjectId == p_objectId) {
CleanupPreloadThread();
AUTOLOCK(m_cacheCS);
auto it = m_cache.find(key);
if (it != m_cache.end()) {
return &it->second;
}
// Preload failed — fall through to synchronous load
}
if (!OpenWorldSI(p_worldId)) {
return nullptr;
}
si::Object* composite = nullptr;
if (!ReadWorldObject(p_worldId, p_objectId, composite)) {
return nullptr;
}
SceneAnimData data;
if (!ParseComposite(composite, data)) {
return nullptr;
}
AUTOLOCK(m_cacheCS);
auto result = m_cache.emplace(key, std::move(data));
return &result.first->second;
}
void Loader::CleanupPreloadThread()
{
if (m_preloadThread) {
delete m_preloadThread;
m_preloadThread = nullptr;
}
}
void Loader::PreloadAsync(int8_t p_preloadWorldId, uint32_t p_preloadObjectId)
{
uint64_t key = CacheKey(p_preloadWorldId, p_preloadObjectId);
{
AUTOLOCK(m_cacheCS);
if (m_cache.find(key) != m_cache.end()) {
return;
}
}
if (m_preloadThread && m_preloadWorldId == p_preloadWorldId && m_preloadObjectId == p_preloadObjectId && !m_preloadDone) {
return;
}
CleanupPreloadThread();
m_preloadWorldId = p_preloadWorldId;
m_preloadObjectId = p_preloadObjectId;
m_preloadDone = false;
m_preloadThread = new PreloadThread(this, p_preloadWorldId, p_preloadObjectId);
m_preloadThread->Start(0x1000, 0);
}
Loader::PreloadThread::PreloadThread(Loader* p_loader, int8_t p_worldId, uint32_t p_objectId)
: m_loader(p_loader), m_worldId(p_worldId), m_objectId(p_objectId)
{
}
MxResult Loader::PreloadThread::Run()
{
const char* siPath = GetSIPath(m_worldId);
if (!siPath) {
m_loader->m_preloadDone = true;
return MxThread::Run();
}
si::File* siFile = nullptr;
si::Interleaf* interleaf = nullptr;
if (!SIReader::OpenHeaderOnly(siPath, siFile, interleaf)) {
m_loader->m_preloadDone = true;
return MxThread::Run();
}
size_t childCount = interleaf->GetChildCount();
if (m_objectId < childCount && interleaf->ReadObject(siFile, m_objectId) == si::Interleaf::ERROR_SUCCESS) {
si::Object* composite = static_cast<si::Object*>(interleaf->GetChildAt(m_objectId));
SceneAnimData data;
if (ParseComposite(composite, data)) {
uint64_t key = CacheKey(m_worldId, m_objectId);
AUTOLOCK(m_loader->m_cacheCS);
m_loader->m_cache.emplace(key, std::move(data));
}
}
m_loader->m_preloadDone = true;
delete interleaf;
delete siFile;
return MxThread::Run();
}

View File

@ -0,0 +1,54 @@
#include "extensions/multiplayer/animation/locationproximity.h"
#include "decomp.h"
#include "legolocations.h"
#include <algorithm>
#include <cmath>
using namespace Multiplayer::Animation;
static const float DEFAULT_RADIUS = 5.0f;
// Location 0 is the camera origin, and the last location is overhead — skip both
static const int FIRST_VALID_LOCATION = 1;
static const int LAST_VALID_LOCATION = sizeOfArray(g_locations) - 2;
LocationProximity::LocationProximity() : m_radius(DEFAULT_RADIUS)
{
}
bool LocationProximity::Update(float p_x, float p_z)
{
std::vector<int16_t> prev = m_locations;
m_locations = ComputeAll(p_x, p_z, m_radius);
return m_locations != prev;
}
bool LocationProximity::IsAtLocation(int16_t p_location) const
{
return std::find(m_locations.begin(), m_locations.end(), p_location) != m_locations.end();
}
void LocationProximity::Reset()
{
m_locations.clear();
}
std::vector<int16_t> LocationProximity::ComputeAll(float p_x, float p_z, float p_radius)
{
std::vector<int16_t> result;
for (int i = FIRST_VALID_LOCATION; i <= LAST_VALID_LOCATION; i++) {
float dx = p_x - g_locations[i].m_position[0];
float dz = p_z - g_locations[i].m_position[2];
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < p_radius) {
result.push_back(static_cast<int16_t>(i));
}
}
// Sorted by index (iteration order is already ascending), which gives stable comparison
return result;
}

View File

@ -0,0 +1,218 @@
#include "extensions/multiplayer/animation/phonemeplayer.h"
#include "extensions/multiplayer/animation/loader.h"
#include "flic.h"
#include "legocharactermanager.h"
#include "misc.h"
#include "misc/legocontainer.h"
#include "mxbitmap.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
using namespace Multiplayer::Animation;
// Find the ROI matching a phoneme track's roiName.
// Check actor aliases first (participant ROIs whose names differ from animation actor names),
// then fall back to a direct name search in the roiMap.
static LegoROI* FindTrackROI(
const std::string& p_roiName,
LegoROI** p_roiMap,
MxU32 p_roiMapSize,
const std::vector<std::pair<std::string, LegoROI*>>& p_actorAliases
)
{
if (p_roiName.empty() || !p_roiMap) {
return nullptr;
}
for (const auto& alias : p_actorAliases) {
if (!SDL_strcasecmp(p_roiName.c_str(), alias.first.c_str())) {
return alias.second;
}
}
for (MxU32 i = 1; i < p_roiMapSize; i++) {
if (p_roiMap[i] && p_roiMap[i]->GetName() && !SDL_strcasecmp(p_roiName.c_str(), p_roiMap[i]->GetName())) {
return p_roiMap[i];
}
}
return nullptr;
}
void PhonemePlayer::Init(
const std::vector<SceneAnimData::PhonemeTrack>& p_tracks,
LegoROI** p_roiMap,
MxU32 p_roiMapSize,
const std::vector<std::pair<std::string, LegoROI*>>& p_actorAliases
)
{
for (size_t trackIdx = 0; trackIdx < p_tracks.size(); trackIdx++) {
auto& track = p_tracks[trackIdx];
PhonemeState state;
state.targetROI = nullptr;
state.originalTexture = nullptr;
state.cachedTexture = nullptr;
state.bitmap = nullptr;
state.currentFrame = -1;
// Resolve the target ROI from the track's roiName via aliases or roiMap
LegoROI* targetROI = FindTrackROI(track.roiName, p_roiMap, p_roiMapSize, p_actorAliases);
if (!targetROI) {
m_states.push_back(state);
continue;
}
state.targetROI = targetROI;
// If a previous track already set up a cached texture for this ROI, reuse it.
// Otherwise the second track's "original" would be the first track's cached texture,
// causing a use-after-free during cleanup.
PhonemeState* existing = nullptr;
for (size_t j = 0; j < m_states.size(); j++) {
if (m_states[j].targetROI == targetROI && m_states[j].cachedTexture) {
existing = &m_states[j];
break;
}
}
if (existing) {
state.cachedTexture = existing->cachedTexture;
state.bitmap = new MxBitmap();
state.bitmap->SetSize(track.width, track.height, nullptr, FALSE);
m_states.push_back(state);
continue;
}
LegoROI* head = targetROI->FindChildROI("head", targetROI);
if (!head) {
m_states.push_back(state);
continue;
}
LegoTextureInfo* originalInfo = nullptr;
head->GetTextureInfo(originalInfo);
if (!originalInfo) {
m_states.push_back(state);
continue;
}
state.originalTexture = originalInfo;
LegoTextureInfo* cached = TextureContainer()->GetCached(originalInfo);
if (!cached) {
m_states.push_back(state);
continue;
}
state.cachedTexture = cached;
CharacterManager()->SetHeadTexture(targetROI, cached);
state.bitmap = new MxBitmap();
state.bitmap->SetSize(track.width, track.height, nullptr, FALSE);
m_states.push_back(state);
}
}
void PhonemePlayer::Tick(float p_elapsedMs, const std::vector<SceneAnimData::PhonemeTrack>& p_tracks)
{
for (size_t i = 0; i < p_tracks.size() && i < m_states.size(); i++) {
auto& track = p_tracks[i];
auto& state = m_states[i];
if (!state.bitmap || !state.cachedTexture) {
continue;
}
float trackElapsed = p_elapsedMs - (float) track.timeOffset;
if (trackElapsed < 0.0f) {
continue;
}
if (track.flcHeader->speed == 0) {
continue;
}
int targetFrame = (int) (trackElapsed / (float) track.flcHeader->speed);
if (targetFrame == state.currentFrame) {
continue;
}
if (targetFrame >= (int) track.frameData.size()) {
continue;
}
int startFrame = state.currentFrame + 1;
if (startFrame < 0) {
startFrame = 0;
}
for (int f = startFrame; f <= targetFrame; f++) {
const auto& data = track.frameData[f];
if (data.size() < sizeof(MxS32)) {
continue;
}
MxS32 rectCount;
SDL_memcpy(&rectCount, data.data(), sizeof(MxS32));
size_t headerSize = sizeof(MxS32) + rectCount * sizeof(MxRect32);
if (data.size() <= headerSize) {
continue;
}
FLIC_FRAME* flcFrame = (FLIC_FRAME*) (data.data() + headerSize);
BYTE decodedColorMap;
DecodeFLCFrame(
&state.bitmap->GetBitmapInfo()->m_bmiHeader,
state.bitmap->GetImage(),
track.flcHeader,
flcFrame,
&decodedColorMap
);
// When the FLC frame updates the palette, apply it to the texture surface
if (decodedColorMap && state.cachedTexture->m_palette) {
PALETTEENTRY entries[256];
RGBQUAD* colors = state.bitmap->GetBitmapInfo()->m_bmiColors;
for (int c = 0; c < 256; c++) {
entries[c].peRed = colors[c].rgbRed;
entries[c].peGreen = colors[c].rgbGreen;
entries[c].peBlue = colors[c].rgbBlue;
entries[c].peFlags = PC_NONE;
}
state.cachedTexture->m_palette->SetEntries(0, 0, 256, entries);
}
}
state.cachedTexture->LoadBits(state.bitmap->GetImage());
state.currentFrame = targetFrame;
}
}
void PhonemePlayer::NotifyROIDestroyed(LegoROI* p_roi)
{
for (auto& state : m_states) {
if (state.targetROI == p_roi) {
state.targetROI = nullptr;
}
}
}
void PhonemePlayer::Cleanup()
{
for (size_t i = 0; i < m_states.size(); i++) {
auto& state = m_states[i];
// Only the state that owns the original texture (i.e. performed the initial setup)
// should restore and erase. Other states sharing the same cachedTexture are secondary.
if (state.targetROI && state.originalTexture) {
CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture);
}
if (state.originalTexture && state.cachedTexture) {
TextureContainer()->EraseCached(state.cachedTexture);
}
delete state.bitmap;
}
m_states.clear();
}

View File

@ -0,0 +1,691 @@
#include "extensions/multiplayer/animation/sceneplayer.h"
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "extensions/common/animutils.h"
#include "extensions/common/charactercloner.h"
#include "extensions/multiplayer/mputils.h"
#include "legoactors.h"
#include "legoanimationmanager.h"
#include "legocameracontroller.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legotree.h"
#include "mxbackgroundaudiomanager.h"
#include "mxgeometry/mxgeometry3d.h"
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include "viewmanager/viewmanager.h"
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <cmath>
#include <deque>
#include <functional>
#include <vector>
using namespace Multiplayer::Animation;
namespace AnimUtils = Extensions::Common::AnimUtils;
using Extensions::Common::CharacterCloner;
static bool MatchesCharacter(const std::string& p_actorName, int8_t p_charIndex)
{
if (p_charIndex < 0 || p_charIndex >= (int8_t) sizeOfArray(g_actorInfoInit)) {
return false;
}
return !SDL_strcasecmp(p_actorName.c_str(), g_actorInfoInit[p_charIndex].m_name);
}
ScenePlayer::ScenePlayer()
: m_loader(nullptr), m_playing(false), m_rebaseComputed(false), m_startTime(0), m_currentData(nullptr),
m_category(e_npcAnim), m_animRootROI(nullptr), m_vehicleROI(nullptr), m_hiddenVehicleROI(nullptr),
m_roiMap(nullptr), m_roiMapSize(0), m_hasCamAnim(false), m_observerMode(false), m_hideOnStop(false)
{
}
ScenePlayer::~ScenePlayer()
{
if (m_playing) {
Stop();
}
}
void ScenePlayer::SetupROIs(const AnimInfo* p_animInfo)
{
LegoU32 numActors = m_currentData->anim->GetNumActors();
std::vector<LegoROI*> createdROIs;
std::vector<AnimUtils::ROIAlias> aliases;
std::deque<std::string> aliasNames;
std::vector<bool> participantMatched(m_participants.size(), false);
static uint32_t s_roiCounter = 0;
// Generate a unique ROI name to avoid collisions in the character manager
// and ViewManager. The counter is global so names stay unique across calls.
auto makeUniqueName = [](char* p_buf, size_t p_size, const char* p_prefix, const char* p_name) {
SDL_snprintf(p_buf, p_size, "%s_%s_%u", p_prefix, p_name, s_roiCounter++);
};
// Register an alias mapping an animation actor name to an ROI whose actual
// name differs (e.g. a participant's unique name, or a cloned scene ROI).
auto addAlias = [&](const std::string& p_name, LegoROI* p_roi) {
aliasNames.push_back(p_name);
aliases.push_back({aliasNames.back().c_str(), p_roi});
m_actorAliases.push_back({p_name, p_roi});
};
// Create a prop ROI from a registered LOD name. Returns nullptr if the
// LOD isn't in the ViewLODListManager.
auto createProp = [&](const std::string& p_name, const char* p_lodName) -> LegoROI* {
char uniqueName[64];
makeUniqueName(uniqueName, sizeof(uniqueName), "npc_prop", p_name.c_str());
LegoROI* roi = CharacterManager()->CreateAutoROI(uniqueName, p_lodName, FALSE);
if (roi) {
roi->SetName(p_name.c_str());
createdROIs.push_back(roi);
}
return roi;
};
// Clone a scene ROI by name. Creates an independent deep copy (shared LOD
// geometry via refcount) with a unique name and an alias for the ROI map.
auto cloneSceneROI = [&](const std::string& p_name) -> LegoROI* {
const CompoundObject& sceneROIs = VideoManager()->Get3DManager()->GetLego3DView()->GetViewManager()->GetROIs();
for (CompoundObject::const_iterator it = sceneROIs.begin(); it != sceneROIs.end(); it++) {
LegoROI* source = (LegoROI*) *it;
if (!source->GetName() || SDL_strcasecmp(source->GetName(), p_name.c_str())) {
continue;
}
char uniqueName[64];
makeUniqueName(uniqueName, sizeof(uniqueName), "npc_scene", p_name.c_str());
LegoROI* clone = Multiplayer::DeepCloneROI(source, uniqueName);
if (clone) {
clone->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Add(*clone);
m_clonedSceneROIs.push_back(clone);
addAlias(p_name, clone);
}
return clone;
}
return nullptr;
};
for (LegoU32 i = 0; i < numActors; i++) {
const char* actorName = m_currentData->anim->GetActorName(i);
LegoU32 actorType = m_currentData->anim->GetActorType(i);
if (!actorName || *actorName == '\0') {
continue;
}
const char* lookupName = (*actorName == '*') ? actorName + 1 : actorName;
std::string lowered(lookupName);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower);
if (actorType == LegoAnimActorEntry::e_managedLegoActor) {
// Character actor: match to a participant or clone as NPC
bool matched = false;
for (size_t p = 0; p < m_participants.size(); p++) {
if (participantMatched[p] || m_participants[p].IsSpectator()) {
continue;
}
if (MatchesCharacter(lowered, m_participants[p].charIndex)) {
participantMatched[p] = true;
matched = true;
addAlias(lowered, m_participants[p].roi);
break;
}
}
if (!matched) {
char uniqueName[64];
makeUniqueName(uniqueName, sizeof(uniqueName), "npc_char", lowered.c_str());
LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str());
if (roi) {
addAlias(lowered, roi);
VideoManager()->Get3DManager()->Add(*roi);
createdROIs.push_back(roi);
}
}
}
else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoiTrimmed || actorType == LegoAnimActorEntry::e_sceneRoi1 || actorType == LegoAnimActorEntry::e_sceneRoi2) {
createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str());
}
else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoi) {
createProp(lowered, lowered.c_str());
}
else {
// Type 0/1: scene actor, vehicle, or prop
LegoROI* roi = nullptr;
// Check if this is a vehicle actor via ModelInfo flag
bool isVehicleActor = false;
for (uint8_t m = 0; m < p_animInfo->m_modelCount; m++) {
if (p_animInfo->m_models[m].m_name &&
!SDL_strcasecmp(lowered.c_str(), p_animInfo->m_models[m].m_name) &&
p_animInfo->m_models[m].m_unk0x2c) {
isVehicleActor = true;
break;
}
}
// Try matching a participant's vehicle by category
if (isVehicleActor && !m_vehicleROI) {
MxU32 animVehicleIdx;
if (AnimationManager()->FindVehicle(lowered.c_str(), animVehicleIdx)) {
for (size_t p = 0; p < m_participants.size(); p++) {
if (!m_participants[p].vehicleROI) {
continue;
}
MxU32 perfVehicleIdx;
if (AnimationManager()->FindVehicle(m_participants[p].vehicleROI->GetName(), perfVehicleIdx)) {
if (Catalog::GetVehicleCategory((int8_t) animVehicleIdx) ==
Catalog::GetVehicleCategory((int8_t) perfVehicleIdx)) {
m_vehicleROI = m_participants[p].vehicleROI;
addAlias(lowered, m_vehicleROI);
roi = m_vehicleROI;
break;
}
}
}
}
}
// Try creating from a registered LOD
if (!roi) {
roi = createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str());
}
// Fallback: clone an existing scene ROI (for models like BIRD
// whose LOD data is embedded in the world, not registered separately)
if (!roi) {
roi = cloneSceneROI(lowered);
}
// Final fallback: borrow local player's vehicle via alias
if (!roi && m_participants[0].vehicleROI && !m_vehicleROI) {
m_vehicleROI = m_participants[0].vehicleROI;
addAlias(lowered, m_vehicleROI);
}
}
}
m_propROIs = std::move(createdROIs);
// Find root ROI: first non-spectator participant matched to an animation actor
LegoROI* rootROI = nullptr;
for (size_t p = 0; p < m_participants.size(); p++) {
if (!m_participants[p].IsSpectator() && participantMatched[p]) {
rootROI = m_participants[p].roi;
break;
}
}
if (!rootROI && !m_participants.empty()) {
rootROI = m_participants[0].roi;
}
if (!rootROI) {
return;
}
m_animRootROI = rootROI;
// Collect extra ROIs (other matched participants + props + vehicle)
std::vector<LegoROI*> extras;
for (size_t p = 0; p < m_participants.size(); p++) {
if (m_participants[p].roi != rootROI && participantMatched[p]) {
extras.push_back(m_participants[p].roi);
}
}
for (auto* propROI : m_propROIs) {
extras.push_back(propROI);
}
for (auto* clonedROI : m_clonedSceneROIs) {
extras.push_back(clonedROI);
}
if (m_vehicleROI) {
extras.push_back(m_vehicleROI);
}
delete[] m_roiMap;
m_roiMap = nullptr;
m_roiMapSize = 0;
AnimUtils::BuildROIMap(
m_currentData->anim,
rootROI,
extras.empty() ? nullptr : extras.data(),
(int) extras.size(),
m_roiMap,
m_roiMapSize,
aliases.empty() ? nullptr : aliases.data(),
(int) aliases.size()
);
}
void ScenePlayer::Play(
const AnimInfo* p_animInfo,
int8_t p_worldId,
AnimCategory p_category,
const ParticipantROI* p_participants,
uint8_t p_participantCount,
bool p_observerMode
)
{
if (m_playing) {
Stop();
}
if (p_participantCount == 0 || !p_participants[0].roi || !p_animInfo) {
return;
}
SceneAnimData* data = m_loader->EnsureCached(p_worldId, p_animInfo->m_objectId);
if (!data || !data->anim) {
return;
}
m_currentData = data;
m_category = p_category;
m_hideOnStop = data->hideOnStop;
m_observerMode = p_observerMode;
// Build participant list with saved transforms for restoration
for (uint8_t i = 0; i < p_participantCount; i++) {
ParticipantROI participant;
participant.roi = p_participants[i].roi;
participant.vehicleROI = p_participants[i].vehicleROI;
participant.savedTransform = p_participants[i].roi->GetLocal2World();
participant.savedName = p_participants[i].roi->GetName();
participant.charIndex = p_participants[i].charIndex;
m_participants.push_back(participant);
}
SetupROIs(p_animInfo);
if (!m_roiMap) {
m_currentData = nullptr;
m_participants.clear();
return;
}
ResolvePtAtCamROIs();
m_phonemePlayer.Init(data->phonemeTracks, m_roiMap, m_roiMapSize, m_actorAliases);
m_audioPlayer.Init(data->audioTracks);
// Observers and spectators don't get camera control — they watch the animation from their own viewpoint
m_hasCamAnim =
(!m_observerMode && !m_participants[0].IsSpectator() && m_category == e_camAnim &&
m_currentData->anim->GetCamAnim() != nullptr);
if (m_category == e_camAnim && !m_observerMode && !m_participants[0].IsSpectator()) {
// Hide the player's ride vehicle — it would remain visible at the
// pre-animation position while the player is teleported
LegoROI* localVehicle = m_participants[0].vehicleROI;
if (localVehicle && localVehicle != m_vehicleROI) {
localVehicle->SetVisibility(FALSE);
m_hiddenVehicleROI = localVehicle;
}
}
m_startTime = 0;
m_playing = true;
BackgroundAudioManager()->LowerVolume();
}
void ScenePlayer::ComputeRebaseMatrix()
{
if (!m_animRootROI) {
m_rebaseMatrix.SetIdentity();
m_rebaseComputed = true;
return;
}
// Use the root performer's saved position as the rebase anchor
MxMatrix targetTransform;
targetTransform.SetIdentity();
for (const auto& p : m_participants) {
if (p.roi == m_animRootROI) {
targetTransform = p.savedTransform;
break;
}
}
// Find the root ROI's world transform at time 0 by walking the animation tree
std::function<bool(LegoTreeNode*, MxMatrix&)> findOrigin = [&](LegoTreeNode* node, MxMatrix& parentWorld) -> bool {
LegoAnimNodeData* data = (LegoAnimNodeData*) node->GetData();
MxU32 roiIdx = data ? data->GetROIIndex() : 0;
MxMatrix localMat;
LegoROI::CreateLocalTransform(data, 0, localMat);
MxMatrix worldMat;
worldMat.Product(localMat, parentWorld);
if (roiIdx != 0 && m_roiMap[roiIdx] == m_animRootROI) {
m_animPose0 = worldMat;
return true;
}
for (LegoU32 i = 0; i < node->GetNumChildren(); i++) {
if (findOrigin(node->GetChild(i), worldMat)) {
return true;
}
}
return false;
};
MxMatrix identity;
identity.SetIdentity();
findOrigin(m_currentData->anim->GetRoot(), identity);
// Inverse of animPose0 (rigid body: transpose rotation, negate translated position)
MxMatrix invAnimPose0;
invAnimPose0.SetIdentity();
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
invAnimPose0[r][c] = m_animPose0[c][r];
}
}
for (int r = 0; r < 3; r++) {
invAnimPose0[3][r] =
-(invAnimPose0[0][r] * m_animPose0[3][0] + invAnimPose0[1][r] * m_animPose0[3][1] +
invAnimPose0[2][r] * m_animPose0[3][2]);
}
m_rebaseMatrix.Product(invAnimPose0, targetTransform);
m_rebaseComputed = true;
}
void ScenePlayer::ResolvePtAtCamROIs()
{
m_ptAtCamROIs.clear();
if (!m_currentData || m_currentData->ptAtCamNames.empty() || !m_roiMap) {
return;
}
for (const auto& name : m_currentData->ptAtCamNames) {
for (MxU32 i = 1; i < m_roiMapSize; i++) {
if (m_roiMap[i] && m_roiMap[i]->GetName() && !SDL_strcasecmp(name.c_str(), m_roiMap[i]->GetName())) {
m_ptAtCamROIs.push_back(m_roiMap[i]);
break;
}
}
}
}
void ScenePlayer::ApplyPtAtCam()
{
if (m_ptAtCamROIs.empty()) {
return;
}
// Find the spectator participant — PTATCAM tracks the spectator's position
// instead of the camera so the actor faces the same target for all players.
LegoROI* spectatorROI = nullptr;
for (const auto& p : m_participants) {
if (p.IsSpectator() && p.roi) {
spectatorROI = p.roi;
break;
}
}
if (!spectatorROI) {
return;
}
// Same math as LegoAnimPresenter::PutFrame
for (LegoROI* roi : m_ptAtCamROIs) {
if (!roi) {
continue;
}
MxMatrix mat(roi->GetLocal2World());
Vector3 pos(mat[0]);
Vector3 dir(mat[1]);
Vector3 up(mat[2]);
Vector3 und(mat[3]);
float possqr = sqrt(pos.LenSquared());
float dirsqr = sqrt(dir.LenSquared());
float upsqr = sqrt(up.LenSquared());
up = und;
up -= spectatorROI->GetWorldPosition();
dir /= dirsqr;
pos.EqualsCross(dir, up);
pos.Unitize();
up.EqualsCross(pos, dir);
pos *= possqr;
dir *= dirsqr;
up *= upsqr;
roi->SetLocal2World(mat);
roi->WrappedUpdateWorldData();
}
}
void ScenePlayer::Tick()
{
if (!m_playing || !m_currentData || m_participants.empty()) {
return;
}
if (m_startTime == 0) {
m_startTime = SDL_GetTicks();
}
if (m_category == e_npcAnim && m_roiMap) {
AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize);
}
float elapsed = (float) (SDL_GetTicks() - m_startTime);
if (elapsed >= m_currentData->duration) {
Stop();
return;
}
// 1. Skeletal animation
if (m_currentData->anim && m_roiMap) {
if (!m_rebaseComputed) {
if (m_category == e_camAnim) {
// cam_anims use the action transform directly (keyframes are in world space)
if (m_currentData->actionTransform.valid) {
Mx3DPointFloat loc(
m_currentData->actionTransform.location[0],
m_currentData->actionTransform.location[1],
m_currentData->actionTransform.location[2]
);
Mx3DPointFloat dir(
m_currentData->actionTransform.direction[0],
m_currentData->actionTransform.direction[1],
m_currentData->actionTransform.direction[2]
);
Mx3DPointFloat up(
m_currentData->actionTransform.up[0],
m_currentData->actionTransform.up[1],
m_currentData->actionTransform.up[2]
);
CalcLocalTransform(loc, dir, up, m_rebaseMatrix);
}
else {
m_rebaseMatrix.SetIdentity();
}
m_rebaseComputed = true;
}
else {
ComputeRebaseMatrix();
}
}
AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap);
}
// 2. Camera animation (cam_anim only)
if (m_hasCamAnim) {
MxMatrix camTransform(m_rebaseMatrix);
m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform);
LegoWorld* world = CurrentWorld();
if (world && world->GetCameraController()) {
world->GetCameraController()->TransformPointOfView(camTransform, FALSE);
}
}
// 3. PTATCAM post-processing
ApplyPtAtCam();
// 4. Audio
const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr;
m_audioPlayer.Tick(elapsed, audioROIName);
// 5. Phoneme frames
m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks);
}
void ScenePlayer::Stop()
{
if (!m_playing) {
return;
}
m_audioPlayer.Cleanup();
m_phonemePlayer.Cleanup();
if (m_hideOnStop && m_roiMap) {
for (MxU32 i = 1; i < m_roiMapSize; i++) {
if (m_roiMap[i]) {
m_roiMap[i]->SetVisibility(FALSE);
}
}
}
if (m_hiddenVehicleROI) {
m_hiddenVehicleROI->SetVisibility(TRUE);
m_hiddenVehicleROI = nullptr;
}
CleanupProps();
m_vehicleROI = nullptr;
delete[] m_roiMap;
m_roiMap = nullptr;
m_roiMapSize = 0;
for (auto& p : m_participants) {
if (p.roi) {
p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform);
p.roi->SetVisibility(TRUE);
}
}
m_participants.clear();
BackgroundAudioManager()->RaiseVolume();
m_ptAtCamROIs.clear();
m_actorAliases.clear();
m_playing = false;
m_rebaseComputed = false;
m_currentData = nullptr;
m_animRootROI = nullptr;
m_hasCamAnim = false;
m_observerMode = false;
m_startTime = 0;
m_hideOnStop = false;
}
void ScenePlayer::NotifyROIDestroyed(LegoROI* p_roi)
{
if (!m_playing || !p_roi) {
return;
}
// Walk the m_roiMap once to find p_roi and all its descendants (child ROIs
// are destroyed together with their parent). Collect them so every other
// field can be cleaned with simple pointer equality — the ancestor walk
// happens in exactly one place.
std::vector<LegoROI*> destroyed;
destroyed.push_back(p_roi);
if (m_roiMap) {
for (MxU32 i = 0; i < m_roiMapSize; i++) {
if (!m_roiMap[i]) {
continue;
}
for (OrientableROI* cur = m_roiMap[i]; cur != nullptr; cur = cur->GetParentROI()) {
if (cur == p_roi) {
if (m_roiMap[i] != p_roi) {
destroyed.push_back(m_roiMap[i]);
}
m_roiMap[i] = nullptr;
break;
}
}
}
}
auto isDestroyed = [&destroyed](LegoROI* roi) {
for (LegoROI* d : destroyed) {
if (roi == d) {
return true;
}
}
return false;
};
for (auto& p : m_participants) {
if (p.roi && isDestroyed(p.roi)) {
p.roi = nullptr;
}
if (p.vehicleROI && isDestroyed(p.vehicleROI)) {
p.vehicleROI = nullptr;
}
}
for (auto& roi : m_ptAtCamROIs) {
if (roi && isDestroyed(roi)) {
roi = nullptr;
}
}
if (m_animRootROI && isDestroyed(m_animRootROI)) {
m_animRootROI = nullptr;
}
if (m_vehicleROI && isDestroyed(m_vehicleROI)) {
m_vehicleROI = nullptr;
}
for (LegoROI* d : destroyed) {
m_phonemePlayer.NotifyROIDestroyed(d);
}
}
void ScenePlayer::CleanupProps()
{
for (auto* propROI : m_propROIs) {
if (propROI) {
CharacterManager()->ReleaseAutoROI(propROI);
}
}
m_propROIs.clear();
for (auto* clonedROI : m_clonedSceneROIs) {
if (clonedROI) {
VideoManager()->Get3DManager()->Remove(*clonedROI);
delete clonedROI;
}
}
m_clonedSceneROIs.clear();
}

View File

@ -0,0 +1,310 @@
#include "extensions/multiplayer/animation/sessionhost.h"
#include "extensions/multiplayer/animation/catalog.h"
#include "extensions/multiplayer/animation/coordinator.h"
#include <SDL3/SDL_timer.h>
using namespace Multiplayer::Animation;
static bool HasAnyFilledSlot(const AnimSession& p_session)
{
for (const auto& slot : p_session.slots) {
if (slot.peerId != 0) {
return true;
}
}
return false;
}
void SessionHost::SetCatalog(const Catalog* p_catalog)
{
m_catalog = p_catalog;
}
AnimSession SessionHost::CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex)
{
AnimSession session;
session.animIndex = p_animIndex;
session.state = CoordinationState::e_interested;
session.countdownEndTime = 0;
for (int8_t i : GetPerformerIndices(p_entry->performerMask)) {
SessionSlot slot;
slot.peerId = 0;
slot.charIndex = i;
session.slots.push_back(slot);
}
SessionSlot spectatorSlot;
spectatorSlot.peerId = 0;
spectatorSlot.charIndex = -1;
session.slots.push_back(spectatorSlot);
return session;
}
bool SessionHost::TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex)
{
for (const auto& slot : p_session.slots) {
if (slot.peerId == p_peerId) {
return false;
}
}
// Performer slots first
for (auto& slot : p_session.slots) {
if (!slot.IsSpectator() && slot.peerId == 0 && slot.charIndex == p_charIndex) {
slot.peerId = p_peerId;
return true;
}
}
// Spectator slot
if (!m_catalog) {
return false;
}
const CatalogEntry* entry = m_catalog->FindEntry(p_session.animIndex);
if (!entry) {
return false;
}
for (auto& slot : p_session.slots) {
if (slot.IsSpectator() && slot.peerId == 0) {
if (p_charIndex >= 0 && !((entry->performerMask >> p_charIndex) & 1) &&
Catalog::CheckSpectatorMask(entry, p_charIndex)) {
slot.peerId = p_peerId;
return true;
}
break;
}
}
return false;
}
bool SessionHost::AllSlotsFilled(const AnimSession& p_session) const
{
for (const auto& slot : p_session.slots) {
if (slot.peerId == 0) {
return false;
}
}
return true;
}
void SessionHost::RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
{
RemovePlayerFromSessions(p_peerId, false, p_changedAnims);
}
void SessionHost::RemovePlayerFromSessions(
uint32_t p_peerId,
bool p_includePlayingSessions,
std::vector<uint16_t>& p_changedAnims
)
{
std::vector<uint16_t> toErase;
for (auto& [animIndex, session] : m_sessions) {
if (!p_includePlayingSessions && session.state == CoordinationState::e_playing) {
continue;
}
bool found = false;
for (auto& slot : session.slots) {
if (slot.peerId == p_peerId) {
slot.peerId = 0;
found = true;
break;
}
}
if (found) {
if (session.state == CoordinationState::e_countdown) {
session.state = CoordinationState::e_interested;
session.countdownEndTime = 0;
}
if (!HasAnyFilledSlot(session)) {
toErase.push_back(animIndex);
}
p_changedAnims.push_back(animIndex);
}
}
for (uint16_t idx : toErase) {
m_sessions.erase(idx);
}
}
bool SessionHost::HandleInterest(
uint32_t p_peerId,
uint16_t p_animIndex,
uint8_t p_displayActorIndex,
std::vector<uint16_t>& p_changedAnims
)
{
if (!m_catalog) {
return false;
}
int8_t charIndex = Catalog::DisplayActorToCharacterIndex(p_displayActorIndex);
RemovePlayerFromAllSessions(p_peerId, p_changedAnims);
const CatalogEntry* entry = m_catalog->FindEntry(p_animIndex);
if (!entry) {
return !p_changedAnims.empty();
}
auto it = m_sessions.find(p_animIndex);
if (it == m_sessions.end()) {
m_sessions[p_animIndex] = CreateSession(entry, p_animIndex);
it = m_sessions.find(p_animIndex);
}
bool assigned = TryAssignSlot(it->second, p_peerId, charIndex);
// Always broadcast: on success the new slot is shown, on failure the rejected
// player's client receives the session state and clears their optimistic interest.
p_changedAnims.push_back(p_animIndex);
// Clean up empty sessions (created but no one could fill a slot)
if (!assigned) {
if (!HasAnyFilledSlot(it->second)) {
m_sessions.erase(it);
}
}
return !p_changedAnims.empty();
}
bool SessionHost::HandleCancel(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
{
RemovePlayerFromSessions(p_peerId, true, p_changedAnims);
// Explicit cancel during playback: erase entire session so all participants stop
for (uint16_t animIndex : p_changedAnims) {
auto it = m_sessions.find(animIndex);
if (it != m_sessions.end() && it->second.state == CoordinationState::e_playing) {
m_sessions.erase(it);
}
}
return !p_changedAnims.empty();
}
bool SessionHost::HandlePlayerRemoved(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
{
RemovePlayerFromSessions(p_peerId, true, p_changedAnims);
return !p_changedAnims.empty();
}
void SessionHost::StartCountdown(uint16_t p_animIndex)
{
auto it = m_sessions.find(p_animIndex);
if (it != m_sessions.end() && it->second.state == CoordinationState::e_interested) {
it->second.state = CoordinationState::e_countdown;
it->second.countdownEndTime = SDL_GetTicks() + COUNTDOWN_DURATION_MS;
}
}
void SessionHost::RevertCountdown(uint16_t p_animIndex)
{
auto it = m_sessions.find(p_animIndex);
if (it != m_sessions.end() && it->second.state == CoordinationState::e_countdown) {
it->second.state = CoordinationState::e_interested;
it->second.countdownEndTime = 0;
}
}
std::vector<uint16_t> SessionHost::Tick(uint32_t p_now)
{
std::vector<uint16_t> ready;
for (auto& [animIndex, session] : m_sessions) {
if (session.state == CoordinationState::e_countdown && p_now >= session.countdownEndTime) {
session.state = CoordinationState::e_playing;
ready.push_back(animIndex);
}
}
return ready;
}
void SessionHost::Reset()
{
m_sessions.clear();
}
void SessionHost::EraseSession(uint16_t p_animIndex)
{
m_sessions.erase(p_animIndex);
}
const AnimSession* SessionHost::FindSession(uint16_t p_animIndex) const
{
auto it = m_sessions.find(p_animIndex);
if (it != m_sessions.end()) {
return &it->second;
}
return nullptr;
}
const std::map<uint16_t, AnimSession>& SessionHost::GetSessions() const
{
return m_sessions;
}
bool SessionHost::AreAllSlotsFilled(uint16_t p_animIndex) const
{
auto it = m_sessions.find(p_animIndex);
if (it == m_sessions.end()) {
return false;
}
return AllSlotsFilled(it->second);
}
uint16_t SessionHost::ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now)
{
if (p_session.state != CoordinationState::e_countdown) {
return 0;
}
if (p_now >= p_session.countdownEndTime) {
return 0;
}
uint32_t remaining = p_session.countdownEndTime - p_now;
if (remaining > 0xFFFF) {
return 0xFFFF;
}
return static_cast<uint16_t>(remaining);
}
bool SessionHost::HasCountdownSession() const
{
for (const auto& [animIndex, session] : m_sessions) {
if (session.state == CoordinationState::e_countdown) {
return true;
}
}
return false;
}
std::vector<int8_t> SessionHost::ComputeSlotCharIndices(const CatalogEntry* p_entry)
{
std::vector<int8_t> indices;
if (!p_entry) {
return indices;
}
// Performers: one slot per set bit in performerMask (same order as CreateSession)
indices = GetPerformerIndices(p_entry->performerMask);
// Spectator slot last
indices.push_back(-1);
return indices;
}

View File

@ -0,0 +1,185 @@
#include "extensions/multiplayer/emoteanimhandler.h"
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "extensions/common/animutils.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "misc.h"
#include "misc/legotree.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#include <algorithm>
#include <string>
#include <vector>
using namespace Multiplayer;
namespace AnimUtils = Extensions::Common::AnimUtils;
// Emote table. Each entry has two phases: {anim, sound}.
// Phase 2 anim is nullptr for one-shot emotes; non-null makes it a multi-part stateful emote.
const EmoteEntry Multiplayer::g_emoteEntries[] = {
{{{"CNs011xx", nullptr}, {nullptr, nullptr}}}, // 0: Wave (one-shot)
{{{"CNs012xx", nullptr}, {nullptr, nullptr}}}, // 1: Hat Tip (one-shot)
{{{"BNsDis01", "crash5"}, {"BNsAss01", nullptr}}}, // 2: Disassemble / Reassemble (multi-part)
{{{"CNs008Br", nullptr}, {nullptr, nullptr}}}, // 3: Look Around (one-shot)
{{{"CNs014Br", nullptr}, {nullptr, nullptr}}}, // 4: Headless (one-shot)
{{{"CNs013Pa", nullptr}, {nullptr, nullptr}}}, // 5: Toss (one-shot)
};
const int Multiplayer::g_emoteAnimCount = sizeof(g_emoteEntries) / sizeof(g_emoteEntries[0]);
bool EmoteAnimHandler::IsValid(uint8_t p_id) const
{
return p_id < g_emoteAnimCount;
}
bool EmoteAnimHandler::IsMultiPart(uint8_t p_id) const
{
return IsMultiPartEmote(p_id);
}
const char* EmoteAnimHandler::GetAnimName(uint8_t p_id, int p_phase) const
{
if (p_id >= g_emoteAnimCount || p_phase < 0 || p_phase > 1) {
return nullptr;
}
return g_emoteEntries[p_id].phases[p_phase].anim;
}
const char* EmoteAnimHandler::GetSoundName(uint8_t p_id, int p_phase) const
{
if (p_id >= g_emoteAnimCount || p_phase < 0 || p_phase > 1) {
return nullptr;
}
return g_emoteEntries[p_id].phases[p_phase].sound;
}
// Read-only tree walk: collect names of animation nodes that don't match the player's ROI hierarchy.
static void CollectUnmatchedNodesRecursive(
LegoTreeNode* p_node,
LegoROI* p_parentROI,
LegoROI* p_rootROI,
std::vector<std::string>& p_unmatchedNames,
bool& p_rootClaimed
)
{
LegoROI* roi = p_parentROI;
LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData();
const char* name = data ? data->GetName() : nullptr;
if (name != nullptr && *name != '-') {
if (*name == '*' || p_parentROI == nullptr) {
roi = p_rootROI;
if (!p_rootClaimed) {
p_rootClaimed = true;
}
else if (*name == '*') {
// Subsequent *-prefixed node: strip prefix, add lowercased name
std::string stripped(name + 1);
std::transform(stripped.begin(), stripped.end(), stripped.begin(), ::tolower);
if (std::find(p_unmatchedNames.begin(), p_unmatchedNames.end(), stripped) == p_unmatchedNames.end()) {
p_unmatchedNames.push_back(stripped);
}
}
}
else {
LegoROI* matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
if (matchedROI == nullptr) {
std::string lowered(name);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower);
if (std::find(p_unmatchedNames.begin(), p_unmatchedNames.end(), lowered) == p_unmatchedNames.end()) {
p_unmatchedNames.push_back(lowered);
}
}
}
}
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
CollectUnmatchedNodesRecursive(p_node->GetChild(i), roi, p_rootROI, p_unmatchedNames, p_rootClaimed);
}
}
static void CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector<std::string>& p_unmatchedNames)
{
if (!p_anim || !p_rootROI) {
return;
}
LegoTreeNode* root = p_anim->GetRoot();
if (!root) {
return;
}
bool rootClaimed = false;
CollectUnmatchedNodesRecursive(root, nullptr, p_rootROI, p_unmatchedNames, rootClaimed);
}
// Resolve a prop node name to its LOD name.
static const char* ResolvePropLODName(const char* p_nodeName)
{
static const struct {
const char* nodePrefix;
const char* lodName;
} mappings[] = {
{"popmug", "pizpie"},
};
for (const auto& m : mappings) {
if (!SDL_strncasecmp(p_nodeName, m.nodePrefix, SDL_strlen(m.nodePrefix))) {
return m.lodName;
}
}
return p_nodeName;
}
void EmoteAnimHandler::BuildProps(
Extensions::Common::PropGroup& p_group,
LegoAnim* p_anim,
LegoROI* p_playerROI,
uint32_t p_propSuffix
)
{
std::vector<std::string> unmatchedNames;
CollectUnmatchedNodes(p_anim, p_playerROI, unmatchedNames);
if (unmatchedNames.empty()) {
return;
}
std::vector<LegoROI*> createdROIs;
for (const std::string& name : unmatchedNames) {
char uniqueName[64];
if (p_propSuffix != 0) {
SDL_snprintf(uniqueName, sizeof(uniqueName), "%s_mp_%u", name.c_str(), p_propSuffix);
}
else {
SDL_snprintf(uniqueName, sizeof(uniqueName), "tp_prop_%s", name.c_str());
}
const char* lodName = ResolvePropLODName(name.c_str());
LegoROI* propROI = CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE);
if (propROI) {
propROI->SetName(name.c_str());
createdROIs.push_back(propROI);
}
}
if (createdROIs.empty()) {
return;
}
p_group.propCount = (uint8_t) createdROIs.size();
p_group.propROIs = new LegoROI*[p_group.propCount];
for (uint8_t i = 0; i < p_group.propCount; i++) {
p_group.propROIs[i] = createdROIs[i];
}
AnimUtils::BuildROIMap(
p_anim,
p_playerROI,
p_group.propROIs,
p_group.propCount,
p_group.roiMap,
p_group.roiMapSize
);
}

View File

@ -0,0 +1,168 @@
#include "extensions/multiplayer/mputils.h"
#include "extensions/common/customizestate.h"
#include "legoactors.h"
#include "legovideomanager.h"
#include "misc.h"
#include "roi/legoroi.h"
#include "viewmanager/viewlodlist.h"
#include <SDL3/SDL_stdinc.h>
using namespace Multiplayer;
LegoROI* Multiplayer::DeepCloneROI(LegoROI* p_source, const char* p_name)
{
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
ViewLODList* lodList = reinterpret_cast<ViewLODList*>(const_cast<LODListBase*>(p_source->GetLODs()));
LegoROI* clone;
if (lodList && lodList->Size() > 0) {
clone = new LegoROI(renderer, lodList);
}
else {
clone = new LegoROI(renderer);
}
clone->SetName(p_name);
clone->SetBoundingSphere(p_source->GetBoundingSphere());
clone->WrappedSetLocal2WorldWithWorldDataUpdate(p_source->GetLocal2World());
const CompoundObject* children = p_source->GetComp();
if (children && !children->empty()) {
CompoundObject* clonedChildren = new CompoundObject();
for (CompoundObject::const_iterator it = children->begin(); it != children->end(); it++) {
LegoROI* childSource = (LegoROI*) *it;
const char* childName = childSource->GetName() ? childSource->GetName() : "";
LegoROI* childClone = DeepCloneROI(childSource, childName);
if (childClone) {
clonedChildren->push_back(childClone);
}
}
clone->SetComp(clonedChildren);
}
return clone;
}
// Inverse of an orthonormal affine matrix (rotation + translation).
// R^-1 = R^T, t^-1 = -R^T * t.
static void InvertOrthonormal(MxMatrix& p_out, const MxMatrix& p_in)
{
p_out.SetIdentity();
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
p_out[r][c] = p_in[c][r];
}
}
for (int c = 0; c < 3; c++) {
p_out[3][c] = -(p_in[3][0] * p_out[0][c] + p_in[3][1] * p_out[1][c] + p_in[3][2] * p_out[2][c]);
}
}
std::vector<MxMatrix> Multiplayer::ComputeChildOffsets(LegoROI* p_parent)
{
std::vector<MxMatrix> offsets;
const CompoundObject* children = p_parent->GetComp();
if (!children) {
return offsets;
}
MxMatrix parentInv;
InvertOrthonormal(parentInv, p_parent->GetLocal2World());
for (auto it = children->begin(); it != children->end(); it++) {
MxMatrix offset;
offset.Product(((LegoROI*) *it)->GetLocal2World(), parentInv);
offsets.push_back(offset);
}
return offsets;
}
void Multiplayer::ApplyHierarchyTransform(
LegoROI* p_parent,
const MxMatrix& p_transform,
const std::vector<MxMatrix>& p_offsets
)
{
p_parent->WrappedSetLocal2WorldWithWorldDataUpdate(p_transform);
const CompoundObject* children = p_parent->GetComp();
if (!children) {
return;
}
size_t i = 0;
for (auto it = children->begin(); it != children->end() && i < p_offsets.size(); it++, i++) {
MxMatrix childWorld;
childWorld.Product(p_offsets[i], p_transform);
((LegoROI*) *it)->WrappedSetLocal2WorldWithWorldDataUpdate(childWorld);
}
}
std::string Multiplayer::TrimLODSuffix(const std::string& p_name)
{
std::string result(p_name);
while (result.size() > 1) {
char c = result.back();
if ((c >= '0' && c <= '9') || c == '_') {
result.pop_back();
}
else {
break;
}
}
return result;
}
void Multiplayer::PackCustomizeState(const Extensions::Common::CustomizeState& p_state, uint8_t p_out[5])
{
// byte 0: hatVariantIndex(5 bits) | reserved(3 bits)
p_out[0] = (p_state.hatVariantIndex & 0x1F);
// byte 1: sound(4 bits) | move(2 bits) | mood(2 bits)
p_out[1] = (p_state.sound & 0x0F) | ((p_state.move & 0x03) << 4) | ((p_state.mood & 0x03) << 6);
// byte 2: infohatColor(4 bits) | infogronColor(4 bits)
p_out[2] = (p_state.colorIndices[c_infohatPart] & 0x0F) | ((p_state.colorIndices[c_infogronPart] & 0x0F) << 4);
// byte 3: armlftColor(4 bits) | armrtColor(4 bits)
p_out[3] = (p_state.colorIndices[c_armlftPart] & 0x0F) | ((p_state.colorIndices[c_armrtPart] & 0x0F) << 4);
// byte 4: leglftColor(4 bits) | legrtColor(4 bits)
p_out[4] = (p_state.colorIndices[c_leglftPart] & 0x0F) | ((p_state.colorIndices[c_legrtPart] & 0x0F) << 4);
}
void Multiplayer::UnpackCustomizeState(Extensions::Common::CustomizeState& p_state, const uint8_t p_in[5])
{
// byte 0: hatVariantIndex(5 bits) | reserved(3 bits)
p_state.hatVariantIndex = p_in[0] & 0x1F;
// byte 1: sound(4 bits) | move(2 bits) | mood(2 bits)
p_state.sound = p_in[1] & 0x0F;
p_state.move = (p_in[1] >> 4) & 0x03;
p_state.mood = (p_in[1] >> 6) & 0x03;
// byte 2: infohatColor(4 bits) | infogronColor(4 bits)
p_state.colorIndices[c_infohatPart] = p_in[2] & 0x0F;
p_state.colorIndices[c_infogronPart] = (p_in[2] >> 4) & 0x0F;
// byte 3: armlftColor(4 bits) | armrtColor(4 bits)
p_state.colorIndices[c_armlftPart] = p_in[3] & 0x0F;
p_state.colorIndices[c_armrtPart] = (p_in[3] >> 4) & 0x0F;
// byte 4: leglftColor(4 bits) | legrtColor(4 bits)
p_state.colorIndices[c_leglftPart] = p_in[4] & 0x0F;
p_state.colorIndices[c_legrtPart] = (p_in[4] >> 4) & 0x0F;
p_state.DeriveDependentIndices();
}
bool Multiplayer::CustomizeStatesEqual(
const Extensions::Common::CustomizeState& p_a,
const Extensions::Common::CustomizeState& p_b
)
{
return SDL_memcmp(&p_a, &p_b, sizeof(Extensions::Common::CustomizeState)) == 0;
}

View File

@ -0,0 +1,330 @@
#include "extensions/multiplayer/namebubblerenderer.h"
#include "3dmanager/lego3dmanager.h"
#include "legovideomanager.h"
#include "misc.h"
#include "roi/legoroi.h"
#include "tgl/tgl.h"
#include <SDL3/SDL_stdinc.h>
#include <vec.h>
using namespace Multiplayer;
// 5x5 bitmap font for A-Z (each row is a byte with bits 4..0 representing columns)
// clang-format off
static const uint8_t g_letterFont[26][5] = {
{0x0E, 0x11, 0x1F, 0x11, 0x11}, // A
{0x1E, 0x11, 0x1E, 0x11, 0x1E}, // B
{0x0F, 0x10, 0x10, 0x10, 0x0F}, // C
{0x1E, 0x11, 0x11, 0x11, 0x1E}, // D
{0x1F, 0x10, 0x1E, 0x10, 0x1F}, // E
{0x1F, 0x10, 0x1E, 0x10, 0x10}, // F
{0x0F, 0x10, 0x13, 0x11, 0x0F}, // G
{0x11, 0x11, 0x1F, 0x11, 0x11}, // H
{0x0E, 0x04, 0x04, 0x04, 0x0E}, // I
{0x01, 0x01, 0x01, 0x11, 0x0E}, // J
{0x11, 0x12, 0x1C, 0x12, 0x11}, // K
{0x10, 0x10, 0x10, 0x10, 0x1F}, // L
{0x11, 0x1B, 0x15, 0x11, 0x11}, // M
{0x11, 0x19, 0x15, 0x13, 0x11}, // N
{0x0E, 0x11, 0x11, 0x11, 0x0E}, // O
{0x1E, 0x11, 0x1E, 0x10, 0x10}, // P
{0x0E, 0x11, 0x15, 0x12, 0x0D}, // Q
{0x1E, 0x11, 0x1E, 0x12, 0x11}, // R
{0x0F, 0x10, 0x0E, 0x01, 0x1E}, // S
{0x1F, 0x04, 0x04, 0x04, 0x04}, // T
{0x11, 0x11, 0x11, 0x11, 0x0E}, // U
{0x11, 0x11, 0x11, 0x0A, 0x04}, // V
{0x11, 0x11, 0x15, 0x1B, 0x11}, // W
{0x11, 0x0A, 0x04, 0x0A, 0x11}, // X
{0x11, 0x0A, 0x04, 0x04, 0x04}, // Y
{0x1F, 0x02, 0x04, 0x08, 0x1F}, // Z
};
// clang-format on
// Texture dimensions (must be power of 2)
static const int TEX_WIDTH = 64;
static const int TEX_HEIGHT = 16;
// Palette indices
static const uint8_t PAL_TRANSPARENT = 0;
static const uint8_t PAL_BLACK = 1;
static const uint8_t PAL_WHITE = 2;
// Billboard world-space size
static const float BUBBLE_WIDTH = 1.2f;
static const float BUBBLE_HEIGHT = 0.3f;
// Vertical offset above ROI bounding sphere
static const float BUBBLE_Y_OFFSET = 0.15f;
NameBubbleRenderer::NameBubbleRenderer()
: m_group(nullptr), m_meshBuilder(nullptr), m_mesh(nullptr), m_texture(nullptr), m_texelData(nullptr),
m_visible(true)
{
}
NameBubbleRenderer::~NameBubbleRenderer()
{
Destroy();
}
void NameBubbleRenderer::GenerateTexture(const char* p_name)
{
m_texelData = new uint8_t[TEX_WIDTH * TEX_HEIGHT];
SDL_memset(m_texelData, PAL_TRANSPARENT, TEX_WIDTH * TEX_HEIGHT);
int nameLen = (int) SDL_strlen(p_name);
if (nameLen <= 0) {
return;
}
// Each letter is 5px wide + 1px spacing; 3px horizontal and 2px vertical padding
int bubbleW = nameLen * 6 - 1 + 6;
int bubbleH = 9;
int bx = SDL_max((TEX_WIDTH - bubbleW) / 2, 0);
int by = SDL_max((TEX_HEIGHT - bubbleH) / 2, 0);
// Draw white bubble background with rounded corners
for (int y = by; y < by + bubbleH && y < TEX_HEIGHT; y++) {
for (int x = bx; x < bx + bubbleW && x < TEX_WIDTH; x++) {
int lx = x - bx;
int ly = y - by;
if ((lx == 0 || lx == bubbleW - 1) && (ly == 0 || ly == bubbleH - 1)) {
continue;
}
m_texelData[y * TEX_WIDTH + x] = PAL_WHITE;
}
}
// Draw black border (top/bottom edges, then left/right edges)
for (int x = bx + 1; x < bx + bubbleW - 1 && x < TEX_WIDTH; x++) {
m_texelData[by * TEX_WIDTH + x] = PAL_BLACK;
m_texelData[(by + bubbleH - 1) * TEX_WIDTH + x] = PAL_BLACK;
}
for (int y = by + 1; y < by + bubbleH - 1 && y < TEX_HEIGHT; y++) {
m_texelData[y * TEX_WIDTH + bx] = PAL_BLACK;
m_texelData[y * TEX_WIDTH + bx + bubbleW - 1] = PAL_BLACK;
}
// Draw text (black on white bubble)
int textX = bx + 3;
int textY = by + 2;
for (int i = 0; i < nameLen; i++) {
char ch = SDL_toupper(p_name[i]);
if (ch < 'A' || ch > 'Z') {
continue;
}
for (int row = 0; row < 5; row++) {
uint8_t bits = g_letterFont[ch - 'A'][row];
for (int col = 0; col < 5; col++) {
if (bits & (1 << (4 - col))) {
int px = textX + i * 6 + col;
int py = textY + row;
if (px < TEX_WIDTH && py < TEX_HEIGHT) {
m_texelData[py * TEX_WIDTH + px] = PAL_BLACK;
}
}
}
}
}
}
void NameBubbleRenderer::CreateQuadMesh()
{
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
if (!renderer) {
return;
}
m_meshBuilder = renderer->CreateMeshBuilder();
if (!m_meshBuilder) {
return;
}
float halfW = BUBBLE_WIDTH * 0.5f;
float halfH = BUBBLE_HEIGHT * 0.5f;
// Vertex order chosen so that triangles (0,1,2) and (0,2,3) have CW winding
// when viewed from +Z, matching the renderer's glFrontFace(GL_CW) setting.
float positions[4][3] = {
{-halfW, -halfH, 0.0f}, // 0: bottom-left
{-halfW, halfH, 0.0f}, // 1: top-left
{halfW, halfH, 0.0f}, // 2: top-right
{halfW, -halfH, 0.0f} // 3: bottom-right
};
float normals[4][3] = {{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f}};
float texCoords[4][2] = {
{0.0f, 1.0f}, // 0: bottom-left of texture
{0.0f, 0.0f}, // 1: top-left
{1.0f, 0.0f}, // 2: top-right
{1.0f, 1.0f} // 3: bottom-right
};
// Tgl::CreateMesh expects packed face indices where each uint32 encodes:
// low 16 bits = position vertex index
// high 16 bits = normal vertex index | 0x8000 (bit 15 = "packed vertex" flag)
// Without the 0x8000 flag, the entry is a simple reference to an already-created
// vertex (no new vertex is allocated). Each packed entry creates a new vertex,
// so shared vertices (0 and 2, used in both triangles) must use simple refs in
// the second triangle to stay within the p_numVertices allocation.
unsigned int faceIndices[2][3] = {
{0x80000000, 0x80010001, 0x80020002}, // create vertices 0, 1, 2
{0x00000000, 0x00000002, 0x80030003} // reuse 0, reuse 2, create vertex 3
};
unsigned int texIndices[2][3] = {
{0, 1, 2},
{0, 0, 3} // only index 5 (value 3) is read; indices 3-4 are simple refs
};
m_mesh = m_meshBuilder->CreateMesh(2, 4, positions, normals, texCoords, faceIndices, texIndices, Tgl::Flat);
}
void NameBubbleRenderer::Create(const char* p_name)
{
if (m_group || !p_name || p_name[0] == '\0') {
return;
}
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
if (!renderer) {
return;
}
// Generate the name texture
GenerateTexture(p_name);
// Create Tgl texture from pixel data
Tgl::PaletteEntry palette[3];
palette[PAL_TRANSPARENT] = {255, 255, 255};
palette[PAL_BLACK] = {0, 0, 0};
palette[PAL_WHITE] = {255, 255, 255};
m_texture = renderer->CreateTexture(TEX_WIDTH, TEX_HEIGHT, 8, m_texelData, TRUE, 3, palette);
if (!m_texture) {
Destroy();
return;
}
// Create the quad mesh
CreateQuadMesh();
if (!m_mesh) {
Destroy();
return;
}
// Apply texture to mesh
m_mesh->SetTexture(m_texture);
m_mesh->SetShadingModel(Tgl::Flat);
// Set alpha < 1.0 so the renderer treats this as transparent (deferred draw
// with blending enabled). The actual per-pixel alpha comes from the texture.
m_mesh->SetColor(1.0f, 1.0f, 1.0f, 254.0f / 255.0f);
// Create a group (D3DRM frame) to hold the billboard
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
m_group = renderer->CreateGroup(scene);
if (!m_group) {
Destroy();
return;
}
m_group->Add(m_meshBuilder);
}
void NameBubbleRenderer::Destroy()
{
if (m_group) {
if (m_visible) {
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
if (scene) {
scene->Remove(m_group);
}
}
delete m_group;
m_group = nullptr;
}
if (m_meshBuilder) {
delete m_meshBuilder;
m_meshBuilder = nullptr;
m_mesh = nullptr; // owned by meshBuilder
}
if (m_texture) {
delete m_texture;
m_texture = nullptr;
}
if (m_texelData) {
delete[] m_texelData;
m_texelData = nullptr;
}
}
void NameBubbleRenderer::SetVisible(bool p_visible)
{
if (m_visible == p_visible || !m_group) {
return;
}
m_visible = p_visible;
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
if (!scene) {
return;
}
if (p_visible) {
scene->Add(m_group);
}
else {
scene->Remove(m_group);
}
}
void NameBubbleRenderer::Update(LegoROI* p_roi)
{
if (!m_group || !p_roi || !m_visible) {
return;
}
LegoROI* viewROI = VideoManager()->GetViewROI();
if (!viewROI) {
return;
}
// Billboard normal = camera's backward-z direction (faces toward camera)
const float* normal = viewROI->GetWorldDirection();
const float* camUp = viewROI->GetWorldUp();
// Build billboard basis vectors
float right[3], up[3];
VXV3(right, camUp, normal);
float rLen = SDL_sqrtf(NORMSQRD3(right));
if (rLen > 0.0001f) {
VDS3(right, right, rLen);
}
VXV3(up, normal, right);
// Position above the player's bounding sphere
const BoundingSphere& sphere = p_roi->GetWorldBoundingSphere();
float pos[3];
SET3(pos, sphere.Center());
pos[1] += sphere.Radius() + BUBBLE_Y_OFFSET;
// Build transformation: rows are right, up, normal, position
Tgl::FloatMatrix4 mat = {};
SET3(mat[0], right);
SET3(mat[1], up);
SET3(mat[2], normal);
SET3(mat[3], pos);
mat[3][3] = 1.0f;
m_group->SetTransformation(mat);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,97 @@
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/emscripten/callbacks.h"
#include <emscripten.h>
namespace Multiplayer
{
void EmscriptenCallbacks::OnPlayerCountChanged(int p_count)
{
// clang-format off
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent('playerCountChanged', {
detail: { count: $0 < 0 ? null : $0 }
}));
}
}, p_count);
// clang-format on
}
// clang-format off
static void DispatchBoolEvent(const char* p_name, bool p_value)
{
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent(UTF8ToString($0), {
detail: { enabled: !!$1 }
}));
}
}, p_name, p_value ? 1 : 0);
}
// clang-format on
void EmscriptenCallbacks::OnThirdPersonChanged(bool p_enabled)
{
DispatchBoolEvent("thirdPersonChanged", p_enabled);
}
void EmscriptenCallbacks::OnNameBubblesChanged(bool p_enabled)
{
DispatchBoolEvent("nameBubblesChanged", p_enabled);
}
void EmscriptenCallbacks::OnAllowCustomizeChanged(bool p_enabled)
{
DispatchBoolEvent("allowCustomizeChanged", p_enabled);
}
void EmscriptenCallbacks::OnConnectionStatusChanged(int p_status)
{
// clang-format off
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent('connectionStatusChanged', {
detail: { status: $0 }
}));
}
}, p_status);
// clang-format on
}
void EmscriptenCallbacks::OnAnimationsAvailable(const char* p_json)
{
// clang-format off
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent('animationsAvailable', {
detail: { json: UTF8ToString($0) }
}));
}
}, p_json);
// clang-format on
}
void EmscriptenCallbacks::OnAnimationCompleted(const char* p_json)
{
// clang-format off
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent('animationCompleted', {
detail: { json: UTF8ToString($0) }
}));
}
}, p_json);
// clang-format on
}
} // namespace Multiplayer
#endif // __EMSCRIPTEN__

View File

@ -0,0 +1,79 @@
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer.h"
#include "extensions/multiplayer/networkmanager.h"
#include <emscripten.h>
using namespace Extensions;
extern "C"
{
EMSCRIPTEN_KEEPALIVE void mp_set_walk_animation(int index)
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestSetWalkAnimation(static_cast<uint8_t>(index));
}
}
EMSCRIPTEN_KEEPALIVE void mp_set_idle_animation(int index)
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestSetIdleAnimation(static_cast<uint8_t>(index));
}
}
EMSCRIPTEN_KEEPALIVE void mp_trigger_emote(int index)
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestSendEmote(static_cast<uint8_t>(index));
}
}
EMSCRIPTEN_KEEPALIVE void mp_toggle_third_person()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestToggleThirdPerson();
}
}
EMSCRIPTEN_KEEPALIVE void mp_toggle_name_bubbles()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestToggleNameBubbles();
}
}
EMSCRIPTEN_KEEPALIVE void mp_toggle_allow_customize()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestToggleAllowCustomize();
}
}
EMSCRIPTEN_KEEPALIVE void mp_set_anim_interest(int animIndex)
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestSetAnimInterest(static_cast<int32_t>(animIndex));
}
}
EMSCRIPTEN_KEEPALIVE void mp_cancel_anim_interest()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestCancelAnimInterest();
}
}
} // extern "C"
#endif

View File

@ -0,0 +1,213 @@
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/emscripten/websockettransport.h"
#include <SDL3/SDL_stdinc.h>
#include <emscripten.h>
namespace Multiplayer
{
WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl)
: m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0), m_disconnectedFlag(0), m_wasEverConnected(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;
m_disconnectedFlag = 0;
m_wasEverConnected = 0;
// clang-format off
m_socketId = MAIN_THREAD_EM_ASM_INT({
var url = UTF8ToString($0);
var connPtr = $1;
var discPtr = $2;
var everConnPtr = $3;
var socketId = Module._mpNextSocketId++;
Module._mpMessageQueues[socketId] = [];
try {
var ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
if (Module._mpSockets[socketId] === ws) {
Atomics.store(HEAP32, connPtr >> 2, 1);
Atomics.store(HEAP32, everConnPtr >> 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() {
if (Module._mpSockets[socketId] === ws) {
Atomics.store(HEAP32, connPtr >> 2, 0);
Atomics.store(HEAP32, discPtr >> 2, 1);
}
};
ws.onerror = function() {
if (Module._mpSockets[socketId] === ws) {
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, &m_disconnectedFlag, &m_wasEverConnected);
// clang-format on
if (m_socketId <= 0) {
m_socketId = -1;
}
}
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
m_socketId = -1;
m_connectedFlag = 0;
m_wasEverConnected = 0;
}
}
bool WebSocketTransport::IsConnected() const
{
return m_socketId > 0 && m_connectedFlag != 0;
}
bool WebSocketTransport::WasDisconnected() const
{
return m_disconnectedFlag != 0;
}
bool WebSocketTransport::WasRejected() const
{
return m_disconnectedFlag != 0 && m_wasEverConnected == 0;
}
void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length)
{
if (m_socketId <= 0 || !m_connectedFlag) {
return;
}
// clang-format off
MAIN_THREAD_EM_ASM({
var socketId = $0;
var dataPtr = $1;
var length = $2;
var ws = Module._mpSockets[socketId];
if (ws && ws.readyState === WebSocket.OPEN) {
var buffer = new Uint8Array(HEAPU8.buffer, dataPtr, length);
var copy = new Uint8Array(length);
copy.set(buffer);
ws.send(copy.buffer);
}
}, m_socketId, p_data, (int) p_length);
// clang-format on
}
size_t WebSocketTransport::Receive(std::function<void(const uint8_t*, size_t)> p_callback)
{
if (m_socketId <= 0) {
return 0;
}
// Drain queued messages in one proxy call: [4-byte LE length][payload...] each.
// 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__

View File

@ -0,0 +1,289 @@
#ifndef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/native/lwstransport.h"
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h>
#include <libwebsockets.h>
namespace Multiplayer
{
static int LwsCallback(struct lws* p_wsi, enum lws_callback_reasons p_reason, void* p_user, void* p_in, size_t p_len)
{
LwsTransport* transport = static_cast<LwsTransport*>(lws_get_opaque_user_data(p_wsi));
if (transport) {
return transport->HandleLwsEvent(p_wsi, static_cast<int>(p_reason), p_in, p_len);
}
return 0;
}
static constexpr size_t LWS_RX_BUFFER_SIZE = 8192;
static constexpr int LWS_SERVICE_TIMEOUT_MS = 50;
// clang-format off
static const struct lws_protocols s_protocols[] = {
{"lws-multiplayer", LwsCallback, 0, LWS_RX_BUFFER_SIZE},
LWS_PROTOCOL_LIST_TERM
};
// clang-format on
MxResult LwsServiceThread::Run()
{
while (IsRunning()) {
m_transport->ServiceLoop();
}
return MxThread::Run();
}
LwsTransport::LwsTransport(const std::string& p_relayBaseUrl)
: m_relayBaseUrl(p_relayBaseUrl), m_context(nullptr), m_wsi(nullptr), m_connected(false), m_disconnected(false),
m_wasEverConnected(false), m_serviceThread(nullptr), m_wantWritable(false)
{
}
LwsTransport::~LwsTransport()
{
Disconnect();
}
void LwsTransport::Connect(const char* p_roomId)
{
if (m_context) {
Disconnect();
}
m_disconnected.store(false);
m_wasEverConnected.store(false);
// lws_parse_uri modifies the string in place, so we need a mutable copy
std::string fullUrl = m_relayBaseUrl + "/room/" + p_roomId;
std::vector<char> urlBuf(fullUrl.begin(), fullUrl.end());
urlBuf.push_back('\0');
const char* protocol = nullptr;
const char* address = nullptr;
const char* path = nullptr;
int port = 0;
if (lws_parse_uri(&urlBuf[0], &protocol, &address, &port, &path)) {
SDL_Log("[Multiplayer] Failed to parse relay URL: %s", fullUrl.c_str());
m_disconnected.store(true);
return;
}
bool useSSL = (SDL_strcmp(protocol, "wss") == 0 || SDL_strcmp(protocol, "https") == 0);
SDL_Log("[Multiplayer] Connecting to %s://%s:%d/%s (SSL=%d)", protocol, address, port, path, useSSL);
lws_set_log_level(LLL_ERR | LLL_WARN, nullptr);
struct lws_context_creation_info ctxInfo;
SDL_memset(&ctxInfo, 0, sizeof(ctxInfo));
ctxInfo.port = CONTEXT_PORT_NO_LISTEN;
ctxInfo.protocols = s_protocols;
if (useSSL) {
ctxInfo.options |= LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT;
}
m_context = lws_create_context(&ctxInfo);
if (!m_context) {
SDL_Log("[Multiplayer] Failed to create lws context");
m_disconnected.store(true);
return;
}
// path from lws_parse_uri does not include the leading '/', so prepend it
std::string fullPath = std::string("/") + path;
struct lws_client_connect_info connInfo;
SDL_memset(&connInfo, 0, sizeof(connInfo));
connInfo.context = m_context;
connInfo.address = address;
connInfo.port = port;
connInfo.path = fullPath.c_str();
connInfo.host = address;
connInfo.origin = address;
connInfo.ssl_connection = useSSL ? (LCCSCF_USE_SSL | LCCSCF_ALLOW_INSECURE) : 0;
connInfo.local_protocol_name = s_protocols[0].name;
connInfo.opaque_user_data = this;
struct lws* wsi = lws_client_connect_via_info(&connInfo);
if (!wsi) {
SDL_Log("[Multiplayer] Failed to initiate WebSocket connection to %s:%d%s", address, port, fullPath.c_str());
lws_context_destroy(m_context);
m_context = nullptr;
m_disconnected.store(true);
return;
}
m_wsi.store(wsi);
m_serviceThread = new LwsServiceThread();
m_serviceThread->SetTransport(this);
if (m_serviceThread->Start(0, 0) != SUCCESS) {
SDL_Log("[Multiplayer] Failed to start WebSocket service thread");
delete m_serviceThread;
m_serviceThread = nullptr;
m_wsi.store(nullptr);
lws_context_destroy(m_context);
m_context = nullptr;
m_disconnected.store(true);
return;
}
}
void LwsTransport::Disconnect()
{
if (m_context) {
lws_cancel_service(m_context);
m_serviceThread->Terminate();
delete m_serviceThread;
m_serviceThread = nullptr;
lws_context_destroy(m_context);
m_context = nullptr;
}
m_wsi.store(nullptr);
m_connected.store(false);
m_wantWritable.store(false);
m_sendCS.Enter();
m_sendQueue.clear();
m_sendCS.Leave();
m_recvCS.Enter();
m_recvQueue.clear();
m_recvCS.Leave();
m_fragment.clear();
}
bool LwsTransport::IsConnected() const
{
return m_connected.load();
}
bool LwsTransport::WasDisconnected() const
{
return m_disconnected.load();
}
bool LwsTransport::WasRejected() const
{
return m_disconnected.load() && !m_wasEverConnected.load();
}
void LwsTransport::Send(const uint8_t* p_data, size_t p_length)
{
if (!m_connected.load() || !m_wsi.load()) {
return;
}
std::vector<uint8_t> buf(LWS_PRE + p_length);
SDL_memcpy(&buf[LWS_PRE], p_data, p_length);
m_sendCS.Enter();
m_sendQueue.push_back(std::move(buf));
m_sendCS.Leave();
m_wantWritable.store(true);
if (m_context) {
lws_cancel_service(m_context);
}
}
size_t LwsTransport::Receive(std::function<void(const uint8_t*, size_t)> p_callback)
{
if (!m_context) {
return 0;
}
std::deque<std::vector<uint8_t>> local;
m_recvCS.Enter();
local.swap(m_recvQueue);
m_recvCS.Leave();
for (const auto& msg : local) {
p_callback(msg.data(), msg.size());
}
return local.size();
}
void LwsTransport::ServiceLoop()
{
if (m_wantWritable.exchange(false)) {
struct lws* wsi = m_wsi.load();
if (wsi) {
lws_callback_on_writable(wsi);
}
}
lws_service(m_context, LWS_SERVICE_TIMEOUT_MS);
}
int LwsTransport::HandleLwsEvent(struct lws* p_wsi, int p_reason, void* p_in, size_t p_len)
{
switch (p_reason) {
case LWS_CALLBACK_CLIENT_ESTABLISHED:
SDL_Log("[Multiplayer] WebSocket connection established");
m_connected.store(true);
m_wasEverConnected.store(true);
break;
case LWS_CALLBACK_CLIENT_RECEIVE:
m_fragment.insert(m_fragment.end(), static_cast<uint8_t*>(p_in), static_cast<uint8_t*>(p_in) + p_len);
if (lws_is_final_fragment(p_wsi)) {
m_recvCS.Enter();
m_recvQueue.push_back(std::move(m_fragment));
m_recvCS.Leave();
m_fragment.clear();
}
break;
case LWS_CALLBACK_CLIENT_WRITEABLE: {
m_sendCS.Enter();
if (!m_sendQueue.empty()) {
std::vector<uint8_t> front = std::move(m_sendQueue.front());
m_sendQueue.pop_front();
bool hasMore = !m_sendQueue.empty();
m_sendCS.Leave();
size_t payloadLen = front.size() - LWS_PRE;
lws_write(p_wsi, &front[LWS_PRE], payloadLen, LWS_WRITE_BINARY);
if (hasMore) {
lws_callback_on_writable(p_wsi);
}
}
else {
m_sendCS.Leave();
}
break;
}
case LWS_CALLBACK_CLIENT_CONNECTION_ERROR:
SDL_Log("[Multiplayer] WebSocket connection error: %s", p_in ? static_cast<const char*>(p_in) : "unknown");
m_disconnected.store(true);
m_connected.store(false);
m_wsi.store(nullptr);
break;
case LWS_CALLBACK_CLIENT_CLOSED:
SDL_Log("[Multiplayer] WebSocket connection closed");
m_disconnected.store(true);
m_connected.store(false);
m_wsi.store(nullptr);
break;
default:
break;
}
return 0;
}
} // namespace Multiplayer
#endif // !__EMSCRIPTEN__

View File

@ -0,0 +1,67 @@
#ifndef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/native/nativecallbacks.h"
#include <SDL3/SDL_log.h>
namespace Multiplayer
{
void NativeCallbacks::OnPlayerCountChanged(int p_count)
{
if (p_count < 0) {
SDL_Log("[Multiplayer] Left multiplayer world");
}
else {
SDL_Log("[Multiplayer] Player count changed: %d", p_count);
}
}
void NativeCallbacks::OnThirdPersonChanged(bool p_enabled)
{
SDL_Log("[Multiplayer] Third person camera: %s", p_enabled ? "enabled" : "disabled");
}
void NativeCallbacks::OnNameBubblesChanged(bool p_enabled)
{
SDL_Log("[Multiplayer] Name bubbles: %s", p_enabled ? "enabled" : "disabled");
}
void NativeCallbacks::OnAllowCustomizeChanged(bool p_enabled)
{
SDL_Log("[Multiplayer] Allow customization: %s", p_enabled ? "enabled" : "disabled");
}
void NativeCallbacks::OnConnectionStatusChanged(int p_status)
{
const char* statusStr = "unknown";
switch (p_status) {
case CONNECTION_STATUS_CONNECTED:
statusStr = "connected";
break;
case CONNECTION_STATUS_RECONNECTING:
statusStr = "reconnecting";
break;
case CONNECTION_STATUS_FAILED:
statusStr = "failed";
break;
case CONNECTION_STATUS_REJECTED:
statusStr = "rejected (room full)";
break;
}
SDL_Log("[Multiplayer] Connection status: %s", statusStr);
}
void NativeCallbacks::OnAnimationsAvailable(const char* p_json)
{
(void) p_json;
}
void NativeCallbacks::OnAnimationCompleted(const char* p_json)
{
SDL_Log("[Multiplayer] Animation completed: %s", p_json);
}
} // namespace Multiplayer
#endif // !__EMSCRIPTEN__

View File

@ -0,0 +1,32 @@
#include "extensions/multiplayer/protocol.h"
#include "legogamestate.h"
#include "misc.h"
#include <SDL3/SDL_stdinc.h>
namespace Multiplayer
{
void EncodeUsername(char p_out[USERNAME_BUFFER_SIZE])
{
SDL_memset(p_out, 0, USERNAME_BUFFER_SIZE);
LegoGameState* gs = GameState();
if (gs && gs->m_playerCount > 0) {
const LegoGameState::Username& username = gs->m_players[0];
for (int i = 0; i < 7; i++) {
MxS16 letter = username.m_letters[i];
if (letter < 0) {
break;
}
if (letter <= 25) {
p_out[i] = (char) ('A' + letter);
}
else {
p_out[i] = '?';
}
}
}
}
} // namespace Multiplayer

View File

@ -0,0 +1,441 @@
#include "extensions/multiplayer/remoteplayer.h"
#include "3dmanager/lego3dmanager.h"
#include "extensions/common/charactercloner.h"
#include "extensions/common/charactercustomizer.h"
#include "extensions/common/charactertables.h"
#include "extensions/multiplayer/emoteanimhandler.h"
#include "extensions/multiplayer/mputils.h"
#include "extensions/multiplayer/namebubblerenderer.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "legoworld.h"
#include "misc.h"
#include "mxgeometry/mxgeometry3d.h"
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <vec.h>
using namespace Extensions;
using namespace Multiplayer;
using Common::DetectVehicleType;
using Common::g_idleAnimCount;
using Common::g_vehicleROINames;
using Common::g_walkAnimCount;
using Common::IsLargeVehicle;
static constexpr float REMOTE_SPEED_THRESHOLD = 0.01f;
static constexpr float POSITION_LERP_FACTOR = 0.2f;
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
: m_peerId(p_peerId), m_actorId(p_actorId), m_displayActorIndex(p_displayActorIndex), m_roi(nullptr),
m_spawned(false), m_visible(false), m_targetSpeed(0.0f), m_targetVehicleType(VEHICLE_NONE),
m_targetWorldId(Multiplayer::WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false),
m_animator(Common::CharacterAnimatorConfig{
/*.saveExtraAnimTransform=*/false,
/*.propSuffix=*/p_peerId,
/*.extraAnimHandler=*/&m_emoteHandler
}),
m_vehicleROI(nullptr), m_vehicleROICloned(false), m_nameBubble(nullptr), m_allowRemoteCustomize(true),
m_lockedForAnimIndex(Animation::ANIM_INDEX_NONE)
{
m_displayName[0] = '\0';
const char* displayName = GetDisplayActorName();
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId);
ZEROVEC3(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;
SET3(m_currentPosition, m_targetPosition);
SET3(m_currentDirection, m_targetDirection);
SET3(m_currentUp, m_targetUp);
}
RemotePlayer::~RemotePlayer()
{
Despawn();
}
bool RemotePlayer::IsAtLocation(int16_t p_location) const
{
return std::find(m_locations.begin(), m_locations.end(), p_location) != m_locations.end();
}
void RemotePlayer::Spawn(LegoWorld* p_isleWorld)
{
if (m_spawned) {
return;
}
LegoCharacterManager* charMgr = CharacterManager();
if (!charMgr) {
return;
}
const char* actorName = GetDisplayActorName();
if (!actorName) {
return;
}
m_roi = Common::CharacterCloner::Clone(charMgr, m_uniqueName, actorName);
if (!m_roi) {
return;
}
VideoManager()->Get3DManager()->Add(*m_roi);
VideoManager()->Get3DManager()->Moved(*m_roi);
m_roi->SetVisibility(FALSE);
m_spawned = true;
m_visible = false;
// Initialize customize state from the display actor's info
uint8_t actorInfoIndex = Common::CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex);
m_customizeState.InitFromActorInfo(actorInfoIndex);
// Build initial walk and idle animation caches
m_animator.InitAnimCaches(m_roi);
// Create name bubble if we already have a name
if (m_displayName[0] != '\0') {
CreateNameBubble();
}
}
void RemotePlayer::Despawn()
{
if (!m_spawned) {
return;
}
m_animator.StopClickAnimation();
DestroyNameBubble();
ExitVehicle();
if (m_roi) {
VideoManager()->Get3DManager()->Remove(*m_roi);
CharacterManager()->ReleaseActor(m_uniqueName);
m_roi = nullptr;
}
// Clear cached animation ROI maps (anim pointers are world-owned).
m_animator.ClearAll();
m_spawned = false;
m_visible = false;
}
const char* RemotePlayer::GetDisplayActorName() const
{
return CharacterManager()->GetActorName(m_displayActorIndex);
}
void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
{
float posDelta = SDL_sqrtf(DISTSQRD3(p_msg.position, m_targetPosition));
SET3(m_targetPosition, p_msg.position);
SET3(m_targetDirection, p_msg.direction);
SET3(m_targetUp, p_msg.up);
m_targetSpeed = posDelta > REMOTE_SPEED_THRESHOLD ? posDelta : 0.0f;
m_targetVehicleType = p_msg.vehicleType;
m_targetWorldId = p_msg.worldId;
m_lastUpdateTime = SDL_GetTicks();
if (!m_hasReceivedUpdate) {
SET3(m_currentPosition, m_targetPosition);
SET3(m_currentDirection, m_targetDirection);
SET3(m_currentUp, m_targetUp);
m_targetSpeed = 0.0f; // No meaningful speed from first sample
m_hasReceivedUpdate = true;
}
// Update display name (can change when player switches save file)
char newName[USERNAME_BUFFER_SIZE];
SDL_memcpy(newName, p_msg.name, sizeof(newName));
newName[USERNAME_BUFFER_SIZE - 1] = '\0';
if (SDL_strcmp(m_displayName, newName) != 0) {
SDL_memcpy(m_displayName, newName, sizeof(m_displayName));
// Recreate bubble with new name (or create for the first time)
if (m_spawned) {
DestroyNameBubble();
CreateNameBubble();
}
}
// Update customize state from packed data
Common::CustomizeState newState;
Multiplayer::UnpackCustomizeState(newState, p_msg.customizeData);
if (!Multiplayer::CustomizeStatesEqual(newState, m_customizeState)) {
uint8_t actorInfoIndex = Common::CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex);
m_customizeState = newState;
if (m_spawned && m_roi) {
Common::CharacterCustomizer::ApplyFullState(m_roi, actorInfoIndex, m_customizeState);
}
}
// Update allow remote customize flag
m_allowRemoteCustomize = (p_msg.customizeFlags & CUSTOMIZE_FLAG_ALLOW_REMOTE) != 0;
// Sync multi-part emote frozen state from remote
bool isFrozen = (p_msg.customizeFlags & CUSTOMIZE_FLAG_FROZEN) != 0;
int8_t frozenEmoteId =
isFrozen
? (int8_t) ((p_msg.customizeFlags >> CUSTOMIZE_FLAG_FROZEN_EMOTE_SHIFT) & CUSTOMIZE_FLAG_FROZEN_EMOTE_MASK)
: -1;
if (frozenEmoteId != m_animator.GetFrozenExtraAnimId()) {
m_animator.SetFrozenExtraAnimId(frozenEmoteId, m_roi);
}
// Swap walk animation if changed
if (p_msg.walkAnimId != m_animator.GetWalkAnimId() && p_msg.walkAnimId < g_walkAnimCount) {
m_animator.SetWalkAnimId(p_msg.walkAnimId, m_roi);
}
// Swap idle animation if changed
if (p_msg.idleAnimId != m_animator.GetIdleAnimId() && p_msg.idleAnimId < g_idleAnimCount) {
m_animator.SetIdleAnimId(p_msg.idleAnimId, m_roi);
}
}
void RemotePlayer::Tick(float p_deltaTime)
{
if (!m_spawned || !m_visible) {
return;
}
// During animation playback, skip transform/animation updates (ScenePlayer drives
// our ROI), but still update the name bubble so it follows the animated position.
if (IsAnimationLocked()) {
if (m_nameBubble) {
m_nameBubble->Update(m_roi);
}
return;
}
UpdateVehicleState();
UpdateTransform(p_deltaTime);
m_animator.Tick(p_deltaTime, m_roi, IsEffectivelyMoving());
// Update name bubble position and billboard orientation
if (m_nameBubble) {
m_nameBubble->Update(m_roi);
}
}
void RemotePlayer::ReAddToScene()
{
if (m_spawned && m_roi) {
VideoManager()->Get3DManager()->Add(*m_roi);
}
if (m_vehicleROI) {
VideoManager()->Get3DManager()->Add(*m_vehicleROI);
}
if (m_animator.GetRideVehicleROI()) {
VideoManager()->Get3DManager()->Add(*m_animator.GetRideVehicleROI());
}
}
void RemotePlayer::SetVisible(bool p_visible)
{
if (!m_spawned || !m_roi) {
return;
}
m_visible = p_visible;
if (p_visible) {
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE && IsLargeVehicle(m_animator.GetCurrentVehicleType())) {
m_roi->SetVisibility(FALSE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(TRUE);
}
}
else {
m_roi->SetVisibility(TRUE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(FALSE);
}
}
}
else {
m_roi->SetVisibility(FALSE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(FALSE);
}
if (m_animator.GetRideVehicleROI()) {
m_animator.GetRideVehicleROI()->SetVisibility(FALSE);
}
}
}
void RemotePlayer::TriggerExtraAnim(uint8_t p_emoteId)
{
if (!m_spawned) {
return;
}
m_animator.TriggerExtraAnim(p_emoteId, m_roi, IsEffectivelyMoving());
}
bool RemotePlayer::IsEffectivelyMoving() const
{
return m_targetSpeed > REMOTE_SPEED_THRESHOLD && m_animator.GetFrozenExtraAnimId() < 0;
}
void RemotePlayer::UpdateTransform(float p_deltaTime)
{
LERP3(m_currentPosition, m_currentPosition, m_targetPosition, POSITION_LERP_FACTOR);
LERP3(m_currentDirection, m_currentDirection, m_targetDirection, POSITION_LERP_FACTOR);
LERP3(m_currentUp, m_currentUp, m_targetUp, POSITION_LERP_FACTOR);
// The network sends forward-z (visual forward). Character meshes face -z,
// so negate to get backward-z for the ROI (mesh faces the correct way).
Mx3DPointFloat pos(m_currentPosition[0], m_currentPosition[1], m_currentPosition[2]);
Mx3DPointFloat dir(-m_currentDirection[0], -m_currentDirection[1], -m_currentDirection[2]);
Mx3DPointFloat up(m_currentUp[0], m_currentUp[1], m_currentUp[2]);
MxMatrix mat;
CalcLocalTransform(pos, dir, up, mat);
m_roi->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
VideoManager()->Get3DManager()->Moved(*m_roi);
if (m_vehicleROI && m_animator.GetCurrentVehicleType() != VEHICLE_NONE &&
IsLargeVehicle(m_animator.GetCurrentVehicleType())) {
if (m_vehicleROICloned && !m_vehicleChildOffsets.empty()) {
Multiplayer::ApplyHierarchyTransform(m_vehicleROI, mat, m_vehicleChildOffsets);
}
else {
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
}
VideoManager()->Get3DManager()->Moved(*m_vehicleROI);
}
}
void RemotePlayer::UpdateVehicleState()
{
if (m_targetVehicleType != m_animator.GetCurrentVehicleType()) {
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) {
ExitVehicle();
}
if (m_targetVehicleType != VEHICLE_NONE) {
EnterVehicle(m_targetVehicleType);
}
}
}
void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
{
if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) {
return;
}
m_animator.SetCurrentVehicleType(p_vehicleType);
m_animator.SetAnimTime(0.0f);
if (IsLargeVehicle(p_vehicleType)) {
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) {
// Fallback for hierarchical models whose root has 0 LODs
// and cannot be created via CreateAutoROI. Deep-clone the world's existing ROI.
LegoROI* source = FindROI(g_vehicleROINames[p_vehicleType]);
if (source) {
m_vehicleROI = Multiplayer::DeepCloneROI(source, vehicleName);
if (m_vehicleROI) {
VideoManager()->Get3DManager()->Add(*m_vehicleROI);
m_vehicleROICloned = true;
}
}
}
if (m_vehicleROI) {
m_roi->SetVisibility(FALSE);
MxMatrix mat(m_roi->GetLocal2World());
if (m_vehicleROICloned) {
m_vehicleChildOffsets = Multiplayer::ComputeChildOffsets(m_vehicleROI);
Multiplayer::ApplyHierarchyTransform(m_vehicleROI, mat, m_vehicleChildOffsets);
}
else {
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
}
m_vehicleROI->SetVisibility(m_visible ? TRUE : FALSE);
}
}
else {
m_animator.BuildRideAnimation(p_vehicleType, m_roi);
}
}
void RemotePlayer::ExitVehicle()
{
if (m_animator.GetCurrentVehicleType() == VEHICLE_NONE) {
return;
}
if (m_vehicleROI) {
VideoManager()->Get3DManager()->Remove(*m_vehicleROI);
if (m_vehicleROICloned) {
delete m_vehicleROI;
}
else {
CharacterManager()->ReleaseAutoROI(m_vehicleROI);
}
m_vehicleROI = nullptr;
m_vehicleROICloned = false;
m_vehicleChildOffsets.clear();
}
m_animator.ClearRideAnimation();
if (m_visible) {
m_roi->SetVisibility(TRUE);
}
m_animator.SetAnimTime(0.0f);
}
void RemotePlayer::CreateNameBubble()
{
if (!m_nameBubble) {
m_nameBubble = new NameBubbleRenderer();
}
m_nameBubble->Create(m_displayName);
}
void RemotePlayer::DestroyNameBubble()
{
if (m_nameBubble) {
m_nameBubble->Destroy();
delete m_nameBubble;
m_nameBubble = nullptr;
}
}
void RemotePlayer::SetNameBubbleVisible(bool p_visible)
{
if (m_nameBubble) {
m_nameBubble->SetVisible(p_visible);
}
}
void RemotePlayer::StopClickAnimation()
{
m_animator.StopClickAnimation();
}

View File

@ -0,0 +1,2 @@
.wrangler/
node_modules/

View File

@ -0,0 +1,6 @@
export const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin" : "*",
"Access-Control-Allow-Methods" : "GET, POST, OPTIONS",
"Access-Control-Allow-Headers" : "Content-Type",
"Cross-Origin-Resource-Policy" : "cross-origin",
};

View File

@ -0,0 +1,264 @@
import {
HEADER_SIZE,
TARGET_BROADCAST,
TARGET_HOST,
TARGET_BROADCAST_ALL,
createAssignIdMsg,
createHostAssignMsg,
createLeaveMsg,
readTarget,
stampSender,
} from "./protocol";
import type { Env } from "./relay";
import { CORS_HEADERS } from "./cors";
export class GameRoom implements DurableObject {
private connections = new Map<number, WebSocket>();
private nextPeerId = 1;
private hostPeerId = 0;
private maxPlayers = 5;
private isPublic = true;
private roomId: string | null = null;
constructor(
private state: DurableObjectState,
private env: Env
) {
state.blockConcurrencyWhile(async () => {
this.isPublic =
(await state.storage.get<boolean>("isPublic")) ?? true;
this.roomId =
(await state.storage.get<string>("roomId")) ?? null;
this.maxPlayers =
(await state.storage.get<number>("maxPlayers")) ?? 5;
});
}
async fetch(request: Request): Promise<Response> {
// Extract roomId from URL path if not yet known
if (!this.roomId) {
const url = new URL(request.url);
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length === 2 && parts[0] === "room") {
this.roomId = parts[1];
await this.state.storage.put("roomId", this.roomId);
}
}
// Handle non-WebSocket requests (HTTP API)
if (request.headers.get("Upgrade") !== "websocket") {
return this.handleHttpRequest(request);
}
// Capacity check
if (this.connections.size >= this.maxPlayers) {
return new Response("Room is full", {
status: 503,
headers: CORS_HEADERS,
});
}
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
const peerId = this.nextPeerId++;
server.accept();
this.connections.set(peerId, server);
this.notifyRegistry().catch(() => {});
server.send(createAssignIdMsg(peerId));
this.assignHostIfNeeded(peerId, server);
server.addEventListener("message", (event) =>
this.handleMessage(event, peerId)
);
const handleDisconnect = () => this.handleDisconnect(peerId);
server.addEventListener("close", handleDisconnect);
server.addEventListener("error", handleDisconnect);
return new Response(null, { status: 101, webSocket: client });
}
private async handleHttpRequest(request: Request): Promise<Response> {
const method = request.method.toUpperCase();
if (method === "POST") {
try {
const body = (await request.json()) as {
maxPlayers?: number;
isPublic?: boolean;
};
const ceiling = this.env.MAX_PLAYERS_CEILING
? Number(this.env.MAX_PLAYERS_CEILING)
: 64;
if (body.maxPlayers !== undefined) {
this.maxPlayers = Math.max(
2,
Math.min(body.maxPlayers, ceiling)
);
await this.state.storage.put(
"maxPlayers",
this.maxPlayers
);
}
if (body.isPublic !== undefined) {
this.isPublic = body.isPublic;
await this.state.storage.put(
"isPublic",
this.isPublic
);
}
} catch {
// Ignore parse errors, keep defaults
}
this.notifyRegistry().catch(() => {});
return new Response(
JSON.stringify({ maxPlayers: this.maxPlayers }),
{
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
}
);
}
if (method === "GET") {
return new Response(
JSON.stringify({
players: this.connections.size,
maxPlayers: this.maxPlayers,
isPublic: this.isPublic,
}),
{
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
}
);
}
return new Response("Method Not Allowed", {
status: 405,
headers: CORS_HEADERS,
});
}
private assignHostIfNeeded(peerId: number, ws: WebSocket): void {
if (this.hostPeerId === 0 || !this.connections.has(this.hostPeerId)) {
this.hostPeerId = peerId;
this.broadcast(createHostAssignMsg(this.hostPeerId));
} else {
this.trySend(ws, createHostAssignMsg(this.hostPeerId));
}
}
private handleDisconnect(peerId: number): void {
this.connections.delete(peerId);
this.broadcast(createLeaveMsg(peerId));
this.notifyRegistry().catch(() => {});
if (peerId === this.hostPeerId) {
this.electNewHost();
}
}
private handleMessage(event: MessageEvent, peerId: number): void {
if (!(event.data instanceof ArrayBuffer)) {
return;
}
const data = new Uint8Array(event.data);
if (data.length < HEADER_SIZE) {
return;
}
const stamped = stampSender(data, peerId);
const target = readTarget(stamped);
if (target === TARGET_BROADCAST) {
this.broadcastExcept(stamped.buffer, peerId);
} else if (target === TARGET_HOST) {
this.sendToHost(stamped);
} else if (target === TARGET_BROADCAST_ALL) {
this.broadcast(stamped.buffer);
} else {
this.sendToTarget(stamped, target);
}
}
private sendToHost(data: Uint8Array): void {
const hostWs = this.connections.get(this.hostPeerId);
if (hostWs) {
this.trySend(hostWs, data.buffer);
}
}
private sendToTarget(data: Uint8Array, targetId: number): void {
const targetWs = this.connections.get(targetId);
if (targetWs) {
if (!this.trySend(targetWs, data.buffer)) {
this.connections.delete(targetId);
}
}
}
private broadcast(msg: ArrayBuffer): void {
for (const ws of this.connections.values()) {
this.trySend(ws, msg);
}
}
private broadcastExcept(msg: ArrayBuffer, excludePeerId: number): void {
for (const [id, ws] of this.connections) {
if (id !== excludePeerId) {
if (!this.trySend(ws, msg)) {
this.connections.delete(id);
}
}
}
}
private trySend(ws: WebSocket, data: ArrayBuffer): boolean {
try {
ws.send(data);
return true;
} catch {
return false;
}
}
private electNewHost(): void {
this.hostPeerId = 0;
for (const id of this.connections.keys()) {
if (this.hostPeerId === 0 || id < this.hostPeerId) {
this.hostPeerId = id;
}
}
if (this.hostPeerId > 0) {
this.broadcast(createHostAssignMsg(this.hostPeerId));
}
}
private async notifyRegistry(): Promise<void> {
if (!this.isPublic || !this.roomId) return;
const id = this.env.ROOM_REGISTRY.idFromName("global");
const registry = this.env.ROOM_REGISTRY.get(id);
await registry.fetch(
new Request("https://registry/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: this.roomId,
players: this.connections.size,
maxPlayers: this.maxPlayers,
}),
})
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
{
"scripts": {
"dev": "wrangler dev --port 8787",
"deploy": "wrangler deploy"
},
"dependencies": {
"wrangler": "^4"
}
}

View File

@ -0,0 +1,64 @@
// Protocol constants — must stay in sync with protocol.h
// MessageHeader binary layout: type(1) + _pad(1) + peerId(4) + sequence(4) + target(4) = 14 bytes
export const HEADER_SIZE = 14;
export const PEER_ID_OFFSET = 2;
export const SEQUENCE_OFFSET = 6;
export const TARGET_OFFSET = 10;
// Routing target constants
export const TARGET_BROADCAST = 0;
export const TARGET_HOST = 0xffffffff;
export const TARGET_BROADCAST_ALL = 0xfffffffe;
// Message types used by server message constructors only.
export const MSG_LEAVE = 2;
export const MSG_HOST_ASSIGN = 4;
export const MSG_ASSIGN_ID = 0xff;
// AssignIdMsg: compact server-only message — type(1) + peerId(4)
const ASSIGN_ID_SIZE = 1 + 4;
// HostAssignMsg: header(14) + hostPeerId(4)
const HOST_ASSIGN_SIZE = HEADER_SIZE + 4;
export function createAssignIdMsg(peerId: number): ArrayBuffer {
const buf = new ArrayBuffer(ASSIGN_ID_SIZE);
const view = new DataView(buf);
view.setUint8(0, MSG_ASSIGN_ID);
view.setUint32(1, peerId, true);
return buf;
}
export function createHostAssignMsg(hostPeerId: number): ArrayBuffer {
const buf = new ArrayBuffer(HOST_ASSIGN_SIZE);
const view = new DataView(buf);
view.setUint8(0, MSG_HOST_ASSIGN);
view.setUint32(PEER_ID_OFFSET, 0, true);
view.setUint32(SEQUENCE_OFFSET, 0, true);
view.setUint32(TARGET_OFFSET, 0, true);
view.setUint32(HEADER_SIZE, hostPeerId, true);
return buf;
}
export function createLeaveMsg(peerId: number): ArrayBuffer {
const buf = new ArrayBuffer(HEADER_SIZE);
const view = new DataView(buf);
view.setUint8(0, MSG_LEAVE);
view.setUint32(PEER_ID_OFFSET, peerId, true);
view.setUint32(SEQUENCE_OFFSET, 0, true);
view.setUint32(TARGET_OFFSET, 0, true);
return buf;
}
/** Copy a message and stamp the sender's peerId into the header. */
export function stampSender(data: Uint8Array, peerId: number): Uint8Array {
const stamped = new Uint8Array(data.length);
stamped.set(data);
new DataView(stamped.buffer).setUint32(PEER_ID_OFFSET, peerId, true);
return stamped;
}
export function readTarget(data: Uint8Array): number {
return new DataView(data.buffer).getUint32(TARGET_OFFSET, true);
}

View File

@ -0,0 +1,45 @@
export { GameRoom } from "./gameroom";
export { RoomRegistry } from "./roomregistry";
export interface Env {
GAME_ROOM: DurableObjectNamespace;
ROOM_REGISTRY: DurableObjectNamespace;
MAX_PLAYERS_CEILING?: number;
}
import { CORS_HEADERS } from "./cors";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const url = new URL(request.url);
const pathParts = url.pathname.split("/").filter(Boolean);
// Route: /rooms (public room listing)
if (url.pathname === "/rooms" && request.method === "GET") {
const id = env.ROOM_REGISTRY.idFromName("global");
const registry = env.ROOM_REGISTRY.get(id);
return registry.fetch(request);
}
// 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 });
},
};

View File

@ -0,0 +1,137 @@
import type { Env } from "./relay";
import { CORS_HEADERS } from "./cors";
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const ALARM_INTERVAL_MS = 60 * 1000; // 60 seconds
const MAX_LISTED_ROOMS = 5;
interface RoomEntry {
roomId: string;
players: number;
maxPlayers: number;
createdAt: number;
updatedAt: number;
}
export class RoomRegistry implements DurableObject {
constructor(
private state: DurableObjectState,
private env: Env
) {}
async fetch(request: Request): Promise<Response> {
const method = request.method.toUpperCase();
if (method === "GET") {
return this.handleList();
}
if (method === "POST") {
return this.handleUpsert(request);
}
return new Response("Method Not Allowed", {
status: 405,
headers: CORS_HEADERS,
});
}
private async handleList(): Promise<Response> {
const entries = await this.getAllRooms();
entries.sort((a, b) => {
if (b.players !== a.players) return b.players - a.players;
return b.createdAt - a.createdAt;
});
const rooms = entries.slice(0, MAX_LISTED_ROOMS).map((e) => ({
roomId: e.roomId,
players: e.players,
maxPlayers: e.maxPlayers,
}));
return new Response(JSON.stringify({ rooms }), {
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
});
}
private async handleUpsert(request: Request): Promise<Response> {
try {
const body = (await request.json()) as {
roomId: string;
players: number;
maxPlayers: number;
};
const key = `room:${body.roomId}`;
const existing =
await this.state.storage.get<RoomEntry>(key);
const now = Date.now();
const entry: RoomEntry = {
roomId: body.roomId,
players: body.players,
maxPlayers: body.maxPlayers,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await this.state.storage.put(key, entry);
await this.ensureAlarm();
return new Response(JSON.stringify({ ok: true }), {
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
});
} catch {
return new Response("Bad Request", {
status: 400,
headers: CORS_HEADERS,
});
}
}
async alarm(): Promise<void> {
const entries = await this.getAllRooms();
const now = Date.now();
const staleKeys: string[] = [];
for (const entry of entries) {
if (now - entry.updatedAt > STALE_THRESHOLD_MS) {
staleKeys.push(`room:${entry.roomId}`);
}
}
if (staleKeys.length > 0) {
await this.state.storage.delete(staleKeys);
}
const remaining = entries.length - staleKeys.length;
if (remaining > 0) {
await this.state.storage.setAlarm(
Date.now() + ALARM_INTERVAL_MS
);
}
}
private async getAllRooms(): Promise<RoomEntry[]> {
const map = await this.state.storage.list<RoomEntry>({
prefix: "room:",
});
return [...map.values()];
}
private async ensureAlarm(): Promise<void> {
const currentAlarm = await this.state.storage.getAlarm();
if (currentAlarm === null) {
await this.state.storage.setAlarm(
Date.now() + ALARM_INTERVAL_MS
);
}
}
}

View File

@ -0,0 +1,22 @@
name = "isle-relay"
main = "relay.ts"
compatibility_date = "2024-01-01"
workers_dev = false
preview_urls = false
[durable_objects]
bindings = [
{ name = "GAME_ROOM", class_name = "GameRoom" },
{ name = "ROOM_REGISTRY", class_name = "RoomRegistry" }
]
[vars]
MAX_PLAYERS_CEILING = 64
[[migrations]]
tag = "v1"
new_sqlite_classes = ["GameRoom"]
[[migrations]]
tag = "v2"
new_sqlite_classes = ["RoomRegistry"]

View File

@ -0,0 +1,163 @@
#include "extensions/multiplayer/sireader.h"
#include "extensions/common/pathutils.h"
#include "mxautolock.h"
#include <SDL3/SDL_stdinc.h>
#include <file.h>
#include <interleaf.h>
using namespace Multiplayer;
SIReader::SIReader() : m_siFile(nullptr), m_interleaf(nullptr), m_siReady(false)
{
}
SIReader::~SIReader()
{
delete m_interleaf;
delete m_siFile;
}
bool SIReader::OpenHeaderOnly(const char* p_siPath, si::File*& p_file, si::Interleaf*& p_interleaf)
{
p_file = new si::File();
MxString path;
if (!Extensions::Common::ResolveGamePath(p_siPath, path) || !p_file->Open(path.GetData(), si::File::Read)) {
delete p_file;
p_file = nullptr;
return false;
}
p_interleaf = new si::Interleaf();
if (p_interleaf->Read(p_file, si::Interleaf::HeaderOnly) != si::Interleaf::ERROR_SUCCESS) {
delete p_interleaf;
p_interleaf = nullptr;
p_file->Close();
delete p_file;
p_file = nullptr;
return false;
}
return true;
}
bool SIReader::Open()
{
if (m_siReady) {
return true;
}
if (!OpenHeaderOnly("\\lego\\scripts\\isle\\isle.si", m_siFile, m_interleaf)) {
return false;
}
m_siReady = true;
return true;
}
bool SIReader::ReadObject(uint32_t p_objectId)
{
if (!m_siReady) {
return false;
}
size_t childCount = m_interleaf->GetChildCount();
if (p_objectId >= childCount) {
return false;
}
si::Object* obj = static_cast<si::Object*>(m_interleaf->GetChildAt(p_objectId));
if (obj->type() != si::MxOb::Null) {
return true;
}
return m_interleaf->ReadObject(m_siFile, p_objectId) == si::Interleaf::ERROR_SUCCESS;
}
si::Object* SIReader::GetObject(uint32_t p_objectId)
{
if (!m_siReady) {
return nullptr;
}
size_t childCount = m_interleaf->GetChildCount();
if (p_objectId >= childCount) {
return nullptr;
}
return static_cast<si::Object*>(m_interleaf->GetChildAt(p_objectId));
}
bool SIReader::ExtractAudioTrack(si::Object* p_child, AudioTrack& p_out)
{
auto& chunks = p_child->data_;
if (chunks.size() < 2) {
return false;
}
// data_[0] = WaveFormat header, data_[1..N] = raw PCM blocks
const auto& header = chunks[0];
if (header.size() < sizeof(MxWavePresenter::WaveFormat)) {
return false;
}
SDL_memcpy(&p_out.format, header.data(), sizeof(MxWavePresenter::WaveFormat));
p_out.pcmData = nullptr;
p_out.pcmDataSize = 0;
p_out.volume = (int32_t) p_child->volume_;
p_out.timeOffset = p_child->time_offset_;
p_out.mediaSrcPath = p_child->filename_;
MxU32 totalPcm = 0;
for (size_t i = 1; i < chunks.size(); i++) {
totalPcm += (MxU32) chunks[i].size();
}
if (totalPcm == 0) {
return false;
}
p_out.pcmData = new MxU8[totalPcm];
p_out.pcmDataSize = totalPcm;
p_out.format.m_dataSize = totalPcm;
MxU32 offset = 0;
for (size_t i = 1; i < chunks.size(); i++) {
SDL_memcpy(p_out.pcmData + offset, chunks[i].data(), chunks[i].size());
offset += (MxU32) chunks[i].size();
}
return true;
}
AudioTrack* SIReader::ExtractFirstAudio(uint32_t p_objectId)
{
if (!Open()) {
return nullptr;
}
if (!ReadObject(p_objectId)) {
return nullptr;
}
si::Object* composite = GetObject(p_objectId);
if (!composite) {
return nullptr;
}
for (size_t i = 0; i < composite->GetChildCount(); i++) {
si::Object* child = static_cast<si::Object*>(composite->GetChildAt(i));
if (child->presenter_.find("MxWavePresenter") != std::string::npos) {
AudioTrack* track = new AudioTrack();
if (ExtractAudioTrack(child, *track)) {
return track;
}
delete track;
break;
}
}
return nullptr;
}

View File

@ -0,0 +1,490 @@
#include "extensions/multiplayer/worldstatesync.h"
#include "isle.h"
#include "legobuildingmanager.h"
#include "legoentity.h"
#include "legogamestate.h"
#include "legomain.h"
#include "legoplantmanager.h"
#include "legoplants.h"
#include "legoutils.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legostorage.h"
#include "mxmisc.h"
#include "mxvariable.h"
#include "mxvariabletable.h"
#include <SDL3/SDL_stdinc.h>
#include <vector>
extern MxU8 g_counters[];
extern MxU8 g_buildingInfoDownshift[];
using namespace Multiplayer;
template <typename T>
void WorldStateSync::SendMessage(const T& p_msg)
{
SendFixedMessage(m_transport, p_msg);
}
WorldStateSync::WorldStateSync()
: m_transport(nullptr), m_localPeerId(0), m_sequence(0), m_isHost(false), m_inIsleWorld(false),
m_snapshotRequested(false), m_savedLightPos(2)
{
}
void WorldStateSync::SaveSkyLightState()
{
const char* bgValue = GameState()->GetBackgroundColor()->GetValue()->GetData();
m_savedSkyColor = bgValue ? bgValue : "set 56 54 68";
m_savedLightPos = SDL_atoi(VariableTable()->GetVariable("lightposition"));
}
void WorldStateSync::RestoreSkyLightState()
{
ApplySkyLightState(m_savedSkyColor.c_str(), m_savedLightPos);
}
void WorldStateSync::ApplySkyLightState(const char* p_skyColor, int p_lightPos)
{
GameState()->GetBackgroundColor()->SetValue(p_skyColor);
GameState()->GetBackgroundColor()->SetLightColor();
SetLightPosition(p_lightPos);
char buf[32];
SDL_snprintf(buf, sizeof(buf), "%d", p_lightPos);
VariableTable()->SetVariable("lightposition", buf);
}
void WorldStateSync::ResetForReconnect()
{
m_localPeerId = 0;
m_sequence = 0;
m_isHost = false;
m_snapshotRequested = false;
m_pendingWorldEvents.clear();
}
void WorldStateSync::OnHostChanged()
{
if (!m_isHost) {
m_snapshotRequested = false;
m_pendingWorldEvents.clear();
SendSnapshotRequest();
}
}
void WorldStateSync::SendWorldSnapshotTo(uint32_t p_targetPeerId)
{
SendWorldSnapshot(p_targetPeerId);
}
void WorldStateSync::HandleRequestSnapshot(const RequestSnapshotMsg& p_msg)
{
if (!m_isHost) {
return;
}
SendWorldSnapshot(p_msg.header.peerId);
}
void WorldStateSync::HandleWorldSnapshot(const uint8_t* p_data, size_t p_length)
{
WorldSnapshotMsg header;
if (!DeserializeMsg(p_data, p_length, header) || header.header.type != MSG_WORLD_SNAPSHOT) {
return;
}
if (p_length < sizeof(WorldSnapshotMsg) + header.dataLength) {
return;
}
const uint8_t* snapshotData = p_data + sizeof(WorldSnapshotMsg);
// Apply the snapshot via LegoMemory.
LegoMemory memory((void*) snapshotData, header.dataLength);
PlantManager()->Read(&memory);
BuildingManager()->Read(&memory);
// Read sky/light state appended after plant + building data.
LegoU32 memPos;
memory.GetPosition(memPos);
const uint8_t* extraData = snapshotData + memPos;
size_t remaining = header.dataLength - memPos;
if (remaining >= 4) {
char skyBuffer[32];
SDL_snprintf(skyBuffer, sizeof(skyBuffer), "set %d %d %d", extraData[0], extraData[1], extraData[2]);
ApplySkyLightState(skyBuffer, extraData[3]);
}
// Read() updates data arrays but not entity positions; reload to refresh.
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
if (world && world->GetWorldId() == LegoOmni::e_act1) {
PlantManager()->Reset(LegoOmni::e_act1);
PlantManager()->LoadWorldInfo(LegoOmni::e_act1);
BuildingManager()->Reset();
BuildingManager()->LoadWorldInfo();
}
}
// Replay events queued while snapshot was in flight.
for (const auto& evt : m_pendingWorldEvents) {
ApplyWorldEvent(evt.entityType, evt.changeType, evt.entityIndex);
}
m_pendingWorldEvents.clear();
m_snapshotRequested = false;
}
void WorldStateSync::HandleWorldEvent(const WorldEventMsg& p_msg)
{
if (m_snapshotRequested) {
m_pendingWorldEvents.push_back(p_msg);
return;
}
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
}
void WorldStateSync::HandleWorldEventRequest(const WorldEventRequestMsg& p_msg)
{
if (!m_isHost) {
return;
}
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
BroadcastWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
}
template <typename TInfo>
static int FindEntityIndex(TInfo* p_infoArray, MxS32 p_count, LegoEntity* p_entity)
{
for (MxS32 i = 0; i < p_count; i++) {
if (p_infoArray[i].m_entity == p_entity) {
return i;
}
}
return -1;
}
MxBool WorldStateSync::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType)
{
if (!m_transport || !m_transport->IsConnected()) {
return FALSE;
}
uint8_t entityType;
int idx;
if (p_entity->GetType() == LegoEntity::e_plant) {
entityType = ENTITY_PLANT;
MxS32 count;
LegoPlantInfo* info = PlantManager()->GetInfoArray(count);
idx = FindEntityIndex(info, count, p_entity);
}
else if (p_entity->GetType() == LegoEntity::e_building) {
entityType = ENTITY_BUILDING;
MxS32 count;
LegoBuildingInfo* info = BuildingManager()->GetInfoArray(count);
idx = FindEntityIndex(info, count, p_entity);
}
else {
return FALSE;
}
if (idx < 0) {
return FALSE;
}
if (m_isHost) {
BroadcastWorldEvent(entityType, p_changeType, (uint8_t) idx);
return FALSE;
}
else {
SendWorldEventRequest(entityType, p_changeType, (uint8_t) idx);
return TRUE;
}
}
MxBool WorldStateSync::HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType)
{
if (!m_transport || !m_transport->IsConnected()) {
return FALSE;
}
if (m_isHost) {
BroadcastWorldEvent(p_entityType, p_changeType, 0);
return FALSE;
}
else {
SendWorldEventRequest(p_entityType, p_changeType, 0);
return TRUE;
}
}
void WorldStateSync::SendSnapshotRequest()
{
RequestSnapshotMsg msg{};
msg.header = {MSG_REQUEST_SNAPSHOT, 0, m_localPeerId, m_sequence++, TARGET_HOST};
SendMessage(msg);
m_snapshotRequested = true;
m_pendingWorldEvents.clear();
}
void WorldStateSync::SendWorldSnapshot(uint32_t p_targetPeerId)
{
if (!m_transport || !m_transport->IsConnected()) {
return;
}
// Serialize plant + building + sky/light state (~1149 bytes max, use 4096 for safety).
uint8_t stateBuffer[4096];
LegoMemory memory(stateBuffer, sizeof(stateBuffer));
PlantManager()->Write(&memory);
BuildingManager()->Write(&memory);
LegoU32 dataLength;
memory.GetPosition(dataLength);
// Append sky color HSV (parse from "set H S V" string) and light position.
int skyH = 56, skyS = 54, skyV = 68; // defaults matching "set 56 54 68"
const char* bgValue = GameState()->GetBackgroundColor()->GetValue()->GetData();
if (bgValue) {
SDL_sscanf(bgValue, "set %d %d %d", &skyH, &skyS, &skyV);
}
int lightPos = SDL_atoi(VariableTable()->GetVariable("lightposition"));
stateBuffer[dataLength++] = (uint8_t) skyH;
stateBuffer[dataLength++] = (uint8_t) skyS;
stateBuffer[dataLength++] = (uint8_t) skyV;
stateBuffer[dataLength++] = (uint8_t) lightPos;
WorldSnapshotMsg msg{};
msg.header = {MSG_WORLD_SNAPSHOT, 0, m_localPeerId, m_sequence++, p_targetPeerId};
msg.dataLength = (uint16_t) dataLength;
std::vector<uint8_t> msgBuf(sizeof(WorldSnapshotMsg) + dataLength);
SDL_memcpy(msgBuf.data(), &msg, sizeof(WorldSnapshotMsg));
SDL_memcpy(msgBuf.data() + sizeof(WorldSnapshotMsg), stateBuffer, dataLength);
m_transport->Send(msgBuf.data(), msgBuf.size());
}
void WorldStateSync::BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
WorldEventMsg msg{};
msg.header = {MSG_WORLD_EVENT, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.entityType = p_entityType;
msg.changeType = p_changeType;
msg.entityIndex = p_entityIndex;
SendMessage(msg);
}
void WorldStateSync::SendWorldEventRequest(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
WorldEventRequestMsg msg{};
msg.header = {MSG_WORLD_EVENT_REQUEST, 0, m_localPeerId, m_sequence++, TARGET_HOST};
msg.entityType = p_entityType;
msg.changeType = p_changeType;
msg.entityIndex = p_entityIndex;
SendMessage(msg);
}
// Dispatch Switch*() calls shared by all entity types.
// Returns true if the change was handled, false for type-specific changes.
static bool DispatchEntitySwitch(LegoEntity* p_entity, uint8_t p_changeType)
{
switch (p_changeType) {
case CHANGE_VARIANT:
p_entity->SwitchVariant();
return true;
case CHANGE_SOUND:
p_entity->SwitchSound();
return true;
case CHANGE_MOVE:
p_entity->SwitchMove();
return true;
case CHANGE_MOOD:
p_entity->SwitchMood();
return true;
default:
return false;
}
}
void WorldStateSync::ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
if (p_entityType == ENTITY_PLANT) {
MxS32 numPlants;
LegoPlantInfo* plantInfo = PlantManager()->GetInfoArray(numPlants);
if (p_entityIndex >= numPlants) {
return;
}
LegoPlantInfo* info = &plantInfo[p_entityIndex];
if (info->m_entity != nullptr) {
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
if (p_changeType == CHANGE_COLOR) {
info->m_entity->SwitchColor(info->m_entity->GetROI());
}
else if (p_changeType == CHANGE_DECREMENT) {
PlantManager()->DecrementCounter(info->m_entity);
}
}
}
else {
switch (p_changeType) {
case CHANGE_VARIANT:
if (info->m_counter == -1) {
info->m_variant++;
if (info->m_variant > LegoPlantInfo::e_palm) {
info->m_variant = LegoPlantInfo::e_flower;
}
if (info->m_move != 0 && info->m_move >= (MxU32) LegoPlantManager::g_maxMove[info->m_variant]) {
info->m_move = LegoPlantManager::g_maxMove[info->m_variant] - 1;
}
}
break;
case CHANGE_SOUND:
info->m_sound++;
if (info->m_sound >= LegoPlantManager::g_maxSound) {
info->m_sound = 0;
}
break;
case CHANGE_MOVE:
info->m_move++;
if (info->m_move >= (MxU32) LegoPlantManager::g_maxMove[info->m_variant]) {
info->m_move = 0;
}
break;
case CHANGE_COLOR:
info->m_color++;
if (info->m_color > LegoPlantInfo::e_green) {
info->m_color = LegoPlantInfo::e_white;
}
break;
case CHANGE_MOOD:
info->m_mood++;
if (info->m_mood > 3) {
info->m_mood = 0;
}
break;
case CHANGE_DECREMENT: {
if (info->m_counter < 0) {
info->m_counter = g_counters[info->m_variant];
}
if (info->m_counter > 0) {
info->m_counter--;
if (info->m_counter == 1) {
info->m_counter = 0;
}
}
break;
}
}
}
}
else if (p_entityType == ENTITY_BUILDING) {
MxS32 numBuildings;
LegoBuildingInfo* buildingInfo = BuildingManager()->GetInfoArray(numBuildings);
if (p_entityIndex >= numBuildings) {
return;
}
LegoBuildingInfo* info = &buildingInfo[p_entityIndex];
if (info->m_entity != nullptr) {
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
if (p_changeType == CHANGE_COLOR) {
info->m_entity->SwitchColor(info->m_entity->GetROI());
}
else if (p_changeType == CHANGE_DECREMENT) {
BuildingManager()->DecrementCounter(info->m_entity);
}
}
}
else {
switch (p_changeType) {
case CHANGE_SOUND:
if (info->m_flags & LegoBuildingInfo::c_hasSounds) {
info->m_sound++;
if (info->m_sound >= LegoBuildingManager::g_maxSound) {
info->m_sound = 0;
}
}
break;
case CHANGE_MOVE:
if (info->m_flags & LegoBuildingInfo::c_hasMoves) {
info->m_move++;
if (info->m_move >= (MxU32) LegoBuildingManager::g_maxMove[p_entityIndex]) {
info->m_move = 0;
}
}
break;
case CHANGE_MOOD:
if (info->m_flags & LegoBuildingInfo::c_hasMoods) {
info->m_mood++;
if (info->m_mood > 3) {
info->m_mood = 0;
}
}
break;
case CHANGE_DECREMENT: {
if (info->m_counter < 0) {
info->m_counter = g_buildingInfoDownshift[p_entityIndex];
}
if (info->m_counter > 0) {
info->m_counter -= 2;
if (info->m_counter == 1) {
info->m_counter = 0;
}
}
break;
}
case CHANGE_VARIANT:
case CHANGE_COLOR:
// Variant switching is config-dependent, color N/A for buildings
break;
}
}
}
else if (p_entityType == ENTITY_SKY) {
switch (p_changeType) {
case SKY_TOGGLE_COLOR:
GameState()->GetBackgroundColor()->ToggleSkyColor();
break;
case SKY_DAY:
GameState()->GetBackgroundColor()->ToggleDayNight(TRUE);
break;
case SKY_NIGHT:
GameState()->GetBackgroundColor()->ToggleDayNight(FALSE);
break;
}
}
else if (p_entityType == ENTITY_LIGHT) {
switch (p_changeType) {
case LIGHT_INCREMENT:
UpdateLightPosition(1);
break;
case LIGHT_DECREMENT:
UpdateLightPosition(-1);
break;
}
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
if (world && world->GetWorldId() == LegoOmni::e_act1) {
((Isle*) world)->UpdateGlobe();
}
}
}
}

View File

@ -30,14 +30,14 @@ void SiLoaderExt::Initialize()
char* files = SDL_strdup(options["si loader:files"].c_str());
char* saveptr;
for (char* file = SDL_strtok_r(files, ",\n\r ", &saveptr); file; file = SDL_strtok_r(NULL, ",\n\r ", &saveptr)) {
for (char* file = SDL_strtok_r(files, ",\n\r ", &saveptr); file; file = SDL_strtok_r(nullptr, ",\n\r ", &saveptr)) {
SiLoaderExt::files.emplace_back(file);
}
char* directives = SDL_strdup(options["si loader:directives"].c_str());
for (char* directive = SDL_strtok_r(directives, ",\n\r ", &saveptr); directive;
directive = SDL_strtok_r(NULL, ",\n\r ", &saveptr)) {
directive = SDL_strtok_r(nullptr, ",\n\r ", &saveptr)) {
SiLoaderExt::directives.emplace_back(directive);
}
@ -58,11 +58,11 @@ bool SiLoaderExt::Load()
return true;
}
std::optional<MxCore*> SiLoaderExt::HandleFind(StreamObject p_object, LegoWorld* world)
std::optional<MxCore*> SiLoaderExt::HandleFind(StreamObject p_object, LegoWorld* p_world)
{
for (const auto& key : replace) {
if (key.first == p_object) {
return world->Find(key.second.first, key.second.second);
return p_world->Find(key.second.first, key.second.second);
}
}
@ -155,17 +155,17 @@ MxBool SiLoaderExt::HandleWorld(LegoWorld* p_world)
return TRUE;
}
std::optional<MxBool> SiLoaderExt::HandleRemove(StreamObject p_object, LegoWorld* world)
std::optional<MxBool> SiLoaderExt::HandleRemove(StreamObject p_object, LegoWorld* p_world)
{
for (const auto& key : removeWith) {
if (key.first == p_object) {
RemoveFromWorld(key.second.first, key.second.second, world->GetAtomId(), world->GetEntityId());
RemoveFromWorld(key.second.first, key.second.second, p_world->GetAtomId(), p_world->GetEntityId());
}
}
for (const auto& key : replace) {
if (key.first == p_object) {
return RemoveFromWorld(key.second.first, key.second.second, world->GetAtomId(), world->GetEntityId());
return RemoveFromWorld(key.second.first, key.second.second, p_world->GetAtomId(), p_world->GetEntityId());
}
}
@ -361,11 +361,11 @@ void SiLoaderExt::ParseExtra(const MxAtomId& p_atom, si::Core* p_core)
}
if ((directive = SDL_strstr(extra.c_str(), "FullScreenMovie"))) {
fullScreenMovie.emplace_back(StreamObject{MxAtomId{atom, e_lowerCase2}, id});
fullScreenMovie.emplace_back(StreamObject{p_atom, object->id_});
}
if ((directive = SDL_strstr(extra.c_str(), "Disable3d"))) {
disable3d.emplace_back(StreamObject{MxAtomId{atom, e_lowerCase2}, id});
disable3d.emplace_back(StreamObject{p_atom, object->id_});
}
}
}

View File

@ -52,7 +52,7 @@ bool TextureLoaderExt::PatchTexture(LegoTextureInfo* p_textureInfo)
desc.ddpfPixelFormat.dwRGBAlphaBitMask = details->Amask;
LPDIRECTDRAW pDirectDraw = VideoManager()->GetDirect3D()->DirectDraw();
if (pDirectDraw->CreateSurface(&desc, &p_textureInfo->m_surface, NULL) != DD_OK) {
if (pDirectDraw->CreateSurface(&desc, &p_textureInfo->m_surface, nullptr) != DD_OK) {
SDL_DestroySurface(surface);
return false;
}
@ -60,7 +60,7 @@ bool TextureLoaderExt::PatchTexture(LegoTextureInfo* p_textureInfo)
memset(&desc, 0, sizeof(desc));
desc.dwSize = sizeof(desc);
if (p_textureInfo->m_surface->Lock(NULL, &desc, DDLOCK_SURFACEMEMORYPTR | DDLOCK_WRITEONLY, NULL) != DD_OK) {
if (p_textureInfo->m_surface->Lock(nullptr, &desc, DDLOCK_SURFACEMEMORYPTR | DDLOCK_WRITEONLY, nullptr) != DD_OK) {
SDL_DestroySurface(surface);
return false;
}
@ -85,7 +85,7 @@ bool TextureLoaderExt::PatchTexture(LegoTextureInfo* p_textureInfo)
}
LPDIRECTDRAWPALETTE ddPalette = nullptr;
if (pDirectDraw->CreatePalette(DDPCAPS_8BIT | DDPCAPS_ALLOW256, entries, &ddPalette, NULL) != DD_OK) {
if (pDirectDraw->CreatePalette(DDPCAPS_8BIT | DDPCAPS_ALLOW256, entries, &ddPalette, nullptr) != DD_OK) {
p_textureInfo->m_surface->Unlock(desc.lpSurface);
SDL_DestroySurface(surface);
return false;
@ -97,7 +97,7 @@ bool TextureLoaderExt::PatchTexture(LegoTextureInfo* p_textureInfo)
memcpy(dst, srcPixels, surface->pitch * surface->h);
p_textureInfo->m_surface->Unlock(desc.lpSurface);
p_textureInfo->m_palette = NULL;
p_textureInfo->m_palette = nullptr;
if (((TglImpl::RendererImpl*) VideoManager()->GetRenderer())
->CreateTextureFromSurface(p_textureInfo->m_surface, &p_textureInfo->m_texture) != D3DRM_OK) {

View File

@ -119,7 +119,7 @@ void Controller::OnActorEnter(IslePathActor* p_actor)
}
// Stop external animation before modifying ride/display state —
// the ScenePlayer may hold a reference to the ride vehicle ROI.
// the animation caller may hold a reference to the ride vehicle ROI.
CancelExternalAnim();
LegoROI* newROI = userActor->GetROI();
@ -143,7 +143,8 @@ void Controller::OnActorEnter(IslePathActor* p_actor)
}
m_active = true;
m_orbit.SetupCamera(userActor);
m_orbit.ResetSmoothedSpeed();
m_orbit.ApplyOrbitCamera();
m_animator.BuildRideAnimation(m_animator.GetCurrentVehicleType(), m_playerROI);
return;
}
@ -174,13 +175,13 @@ void Controller::OnActorExit(IslePathActor* p_actor)
}
// Stop external animation before clearing ride animation state —
// the ScenePlayer may hold a reference to the ride vehicle ROI.
// the animation caller may hold a reference to the ride vehicle ROI.
CancelExternalAnim();
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) {
m_animator.ClearRideAnimation();
m_animator.ClearAll();
ReinitForCharacter();
ReinitForCharacter(/*p_preserveCamera=*/true);
}
else if (m_active && static_cast<LegoPathActor*>(p_actor) == UserActor()) {
if (m_playerROI) {
@ -240,7 +241,8 @@ void Controller::Tick(float p_deltaTime)
}
}
if (!m_animPlaying && (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled)) {
if ((!m_animPlaying || !m_animLockDisplay) &&
(!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled)) {
m_orbit.ApplyOrbitCamera();
}
@ -288,16 +290,13 @@ void Controller::Tick(float p_deltaTime)
return;
}
// When an external animation is playing, prevent movement.
// If the display ROI is being driven by the animation (performer), skip everything.
// If spectating, still sync + idle animate.
if (m_animPlaying) {
// When performing in an external animation, prevent movement and skip display updates
// (the animation drives positioning). Spectators are free to move.
if (m_animPlaying && m_animLockDisplay) {
userActor->SetWorldSpeed(0.0f);
NavController()->SetLinearVel(0.0f);
if (m_animLockDisplay) {
return;
}
}
// Sync display clone position from native ROI
if (m_display.GetDisplayROI() && m_display.GetDisplayROI() == m_playerROI) {
@ -418,7 +417,7 @@ MxBool Controller::HandleCameraRelativeMovement(
p_newPos,
p_newDir,
p_deltaTime,
m_animator.IsExtraAnimBlocking() || m_animPlaying,
m_animator.IsExtraAnimBlocking() || (m_animPlaying && m_animLockDisplay),
m_input.IsLmbHeldForMovement()
);
}
@ -453,7 +452,7 @@ MxBool Controller::HandleFirstPersonForward(
return TRUE;
}
void Controller::ReinitForCharacter()
void Controller::ReinitForCharacter(bool p_preserveCamera)
{
if (!GameState() || IsRestrictedArea(GameState()->m_currentArea)) {
m_active = false;
@ -521,6 +520,12 @@ void Controller::ReinitForCharacter()
m_animator.ApplyIdleFrame0(m_playerROI);
if (!m_pendingWorldTransition) {
if (p_preserveCamera) {
m_orbit.ResetSmoothedSpeed();
m_orbit.ApplyOrbitCamera();
}
else {
m_orbit.SetupCamera(userActor);
}
}
}

Some files were not shown because too many files have changed in this diff Show More