Add multiplayer extension

This commit is contained in:
Christian Semmler 2026-04-02 17:24:17 -07:00
parent 7e4a86fb39
commit 90d11d98e0
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
103 changed files with 12328 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,16 @@ 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")
add_custom_target(emscripten_version ALL
COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${CMAKE_SOURCE_DIR} -DOUTPUT_DIR=${ISLE_EMSCRIPTEN_VERSION_DIR}
-P ${CMAKE_SOURCE_DIR}/CMake/EmscriptenVersion.cmake
BYPRODUCTS ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js ${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL
)
endif()
if (NINTENDO_SWITCH)
@ -56,6 +63,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 +552,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 +649,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:
result = HandleClick();
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,38 @@
#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();
private:
std::vector<PhonemeState> m_states;
};
} // namespace Multiplayer::Animation

View File

@ -0,0 +1,105 @@
#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();
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,79 @@
#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 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,253 @@
#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);
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);
void BroadcastAnimComplete(uint16_t p_animIndex);
void HandleAnimComplete(const AnimCompleteMsg& p_msg);
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;
void TickAnimation();
void StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes);
void StopAllPlayback();
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,282 @@
#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_ANIM_COMPLETE = 15,
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;
};
// Per-participant data in AnimCompleteMsg
struct AnimCompletionParticipant {
uint32_t peerId;
int8_t charIndex; // Participant's character (g_actorInfoInit index)
char displayName[USERNAME_BUFFER_SIZE]; // 7 chars + null
};
// Host -> All: animation completed successfully (natural completion only, not cancellation)
struct AnimCompleteMsg {
MessageHeader header;
uint64_t eventId; // Random 64-bit ID unique to this completion event
uint16_t animIndex; // World-encoded animation index (globally unique key)
uint8_t participantCount;
AnimCompletionParticipant participants[8];
};
#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,120 @@
#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;
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,16 +62,16 @@ 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; }
@ -95,7 +95,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 +111,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 +129,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,209 @@
#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::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,605 @@
#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);
// 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];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_prop_%s", 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;
}
static uint32_t s_counter = 0;
char uniqueName[64];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_scene_%s_%u", p_name.c_str(), s_counter++);
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];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_char_%s", lowered.c_str());
LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str());
if (roi) {
roi->SetName(lowered.c_str());
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;
}
LegoWorld* world = CurrentWorld();
if (!world || !world->GetCameraController()) {
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 -= world->GetCameraController()->GetWorldLocation();
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) {
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::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,15 +290,12 @@ 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;
}
return;
}
// Sync display clone position from native ROI
@ -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) {
m_orbit.SetupCamera(userActor);
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