diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index 2f21379d..cb3918b7 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -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() + diff --git a/3rdparty/patch_mbedtls_cmake.cmake b/3rdparty/patch_mbedtls_cmake.cmake new file mode 100644 index 00000000..060da92e --- /dev/null +++ b/3rdparty/patch_mbedtls_cmake.cmake @@ -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}") diff --git a/CMake/EmscriptenVersion.cmake b/CMake/EmscriptenVersion.cmake new file mode 100644 index 00000000..655bb2d7 --- /dev/null +++ b/CMake/EmscriptenVersion.cmake @@ -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") diff --git a/CMakeLists.txt b/CMakeLists.txt index 85dc479a..be7cc420 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,9 +13,38 @@ if (WINDOWS_STORE) endif() if (EMSCRIPTEN) - add_compile_options(-pthread) - add_link_options(-sUSE_WEBGL2=1 -sMIN_WEBGL_VERSION=2 -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=2gb -sUSE_PTHREADS=1 -sPROXY_TO_PTHREAD=1 -sOFFSCREENCANVAS_SUPPORT=1 -sPTHREAD_POOL_SIZE_STRICT=0 -sFORCE_FILESYSTEM=1 -sWASMFS=1 -sEXIT_RUNTIME=1) + add_compile_options(-pthread -gsource-map) + add_link_options(-sUSE_WEBGL2=1 -sMIN_WEBGL_VERSION=2 -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=2gb -sUSE_PTHREADS=1 -sPROXY_TO_PTHREAD=1 -sOFFSCREENCANVAS_SUPPORT=1 -sPTHREAD_POOL_SIZE_STRICT=0 -sFORCE_FILESYSTEM=1 -sWASMFS=1 -sEXIT_RUNTIME=1 -sABORT_ON_WASM_EXCEPTIONS=1 -gsource-map) set(SDL_PTHREADS ON CACHE BOOL "Enable SDL pthreads" FORCE) + find_program(LLVM_OBJCOPY_BIN NAMES llvm-objcopy HINTS "${EMSCRIPTEN_ROOT_PATH}/../bin" REQUIRED) + set(ISLE_EMSCRIPTEN_VERSION_DIR "${CMAKE_BINARY_DIR}/generated") + + # Resolve git HEAD to find file dependencies for change detection. + # .git/HEAD changes on branch switch; the ref file it points to changes on commit. + set(_git_head "${CMAKE_SOURCE_DIR}/.git/HEAD") + set(_git_deps "${_git_head}") + if(EXISTS "${_git_head}") + file(READ "${_git_head}" _head_ref) + string(STRIP "${_head_ref}" _head_ref) + if(_head_ref MATCHES "^ref: (.+)$") + set(_git_ref "${CMAKE_SOURCE_DIR}/.git/${CMAKE_MATCH_1}") + if(EXISTS "${_git_ref}") + list(APPEND _git_deps "${_git_ref}") + endif() + endif() + endif() + + add_custom_command( + OUTPUT ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js ${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL + COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${CMAKE_SOURCE_DIR} -DOUTPUT_DIR=${ISLE_EMSCRIPTEN_VERSION_DIR} + -P ${CMAKE_SOURCE_DIR}/CMake/EmscriptenVersion.cmake + DEPENDS ${_git_deps} + COMMENT "Generating emscripten version files" + ) + add_custom_target(emscripten_version DEPENDS + ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js + ${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL + ) endif() if (NINTENDO_SWITCH) @@ -56,6 +85,7 @@ option(ISLE_WERROR "Treat warnings as errors" OFF) cmake_dependent_option(ISLE_USE_DX5 "Build with internal DirectX 5 SDK" "${NOT_MINGW}" "WIN32;CMAKE_SIZEOF_VOID_P EQUAL 4" OFF) cmake_dependent_option(ISLE_MINIWIN "Use miniwin" ON "NOT ISLE_USE_DX5" OFF) cmake_dependent_option(ISLE_EXTENSIONS "Use extensions" ON "NOT ISLE_USE_DX5;NOT WINDOWS_STORE" OFF) +cmake_dependent_option(ISLE_USE_LWS "Use libwebsockets for native multiplayer" ON "ISLE_EXTENSIONS;NOT EMSCRIPTEN;NOT NINTENDO_3DS;NOT NINTENDO_SWITCH;NOT VITA" OFF) cmake_dependent_option(ISLE_BUILD_CONFIG "Build CONFIG.EXE application" ON "MSVC OR ISLE_MINIWIN;NOT NINTENDO_3DS;NOT NINTENDO_SWITCH;NOT WINDOWS_STORE;NOT VITA" OFF) cmake_dependent_option(ISLE_COMPILE_SHADERS "Compile shaders" ON "SDL_SHADERCROSS_BIN;TARGET Python3::Interpreter" OFF) cmake_dependent_option(CMAKE_POSITION_INDEPENDENT_CODE "Build with -fPIC" ON "NOT VITA" OFF) @@ -544,10 +574,48 @@ if (ISLE_EXTENSIONS) # Third person camera extension extensions/src/thirdpersoncamera.cpp extensions/src/thirdpersoncamera/controller.cpp - extensions/src/thirdpersoncamera/orbitcamera.cpp - extensions/src/thirdpersoncamera/inputhandler.cpp extensions/src/thirdpersoncamera/displayactor.cpp + extensions/src/thirdpersoncamera/inputhandler.cpp + extensions/src/thirdpersoncamera/orbitcamera.cpp + + # Multiplayer extension + extensions/src/multiplayer.cpp + extensions/src/multiplayer/animation/audioplayer.cpp + extensions/src/multiplayer/animation/catalog.cpp + extensions/src/multiplayer/animation/coordinator.cpp + extensions/src/multiplayer/animation/loader.cpp + extensions/src/multiplayer/animation/locationproximity.cpp + extensions/src/multiplayer/animation/phonemeplayer.cpp + extensions/src/multiplayer/animation/sceneplayer.cpp + extensions/src/multiplayer/animation/sessionhost.cpp + extensions/src/multiplayer/emoteanimhandler.cpp + extensions/src/multiplayer/mputils.cpp + extensions/src/multiplayer/namebubblerenderer.cpp + extensions/src/multiplayer/networkmanager.cpp + extensions/src/multiplayer/protocol.cpp + extensions/src/multiplayer/remoteplayer.cpp + extensions/src/multiplayer/sireader.cpp + extensions/src/multiplayer/worldstatesync.cpp ) + if(EMSCRIPTEN) + target_sources(lego1 PRIVATE + extensions/src/multiplayer/platforms/emscripten/callbacks.cpp + extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp + ) + elseif(ISLE_USE_LWS) + target_sources(lego1 PRIVATE + extensions/src/multiplayer/platforms/native/lwstransport.cpp + extensions/src/multiplayer/platforms/native/nativecallbacks.cpp + ) + # Skip precompiled headers for native transport files to avoid miniwin/windows.h conflicts + set_source_files_properties( + extensions/src/multiplayer/platforms/native/lwstransport.cpp + extensions/src/multiplayer/platforms/native/nativecallbacks.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS ON + ) + target_compile_definitions(lego1 PRIVATE ISLE_USE_LWS) + target_link_libraries(lego1 PRIVATE websockets) + endif() endif() if (ISLE_BUILD_APP) @@ -603,7 +671,21 @@ if (ISLE_BUILD_APP) ISLE/emscripten/messagebox.cpp ISLE/emscripten/window.cpp ) + if(ISLE_EXTENSIONS) + target_sources(isle PRIVATE + extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp + ) + endif() target_compile_definitions(isle PRIVATE "ISLE_EMSCRIPTEN_HOST=\"${ISLE_EMSCRIPTEN_HOST}\"") + add_dependencies(isle emscripten_version) + target_link_options(isle PRIVATE --post-js ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js) + set_property(TARGET isle PROPERTY LINK_DEPENDS ${ISLE_EMSCRIPTEN_VERSION_DIR}/version.js) + add_custom_command(TARGET isle POST_BUILD + COMMAND ${LLVM_OBJCOPY_BIN} --remove-section=sourceMappingURL + --add-section=sourceMappingURL=${ISLE_EMSCRIPTEN_VERSION_DIR}/sourceMappingURL + $/isle.wasm + COMMENT "Patching sourceMappingURL with build-time git hash" + ) set_property(TARGET isle PROPERTY SUFFIX ".html") endif() if(NINTENDO_3DS) diff --git a/ISLE/emscripten/events.cpp b/ISLE/emscripten/events.cpp index 436c1b64..58a744cb 100644 --- a/ISLE/emscripten/events.cpp +++ b/ISLE/emscripten/events.cpp @@ -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); +} diff --git a/ISLE/emscripten/events.h b/ISLE/emscripten/events.h index a22d7b55..d44bcace 100644 --- a/ISLE/emscripten/events.h +++ b/ISLE/emscripten/events.h @@ -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 diff --git a/ISLE/isleapp.cpp b/ISLE/isleapp.cpp index 85b702b9..52633a00 100644 --- a/ISLE/isleapp.cpp +++ b/ISLE/isleapp.cpp @@ -37,6 +37,7 @@ #include "viewmanager/viewmanager.h" #include +#include #include #include #include @@ -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; } diff --git a/LEGO1/lego/legoomni/include/isle.h b/LEGO1/lego/legoomni/include/isle.h index eafec8fe..bc794582 100644 --- a/LEGO1/lego/legoomni/include/isle.h +++ b/LEGO1/lego/legoomni/include/isle.h @@ -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' diff --git a/LEGO1/lego/legoomni/include/legoanimationmanager.h b/LEGO1/lego/legoomni/include/legoanimationmanager.h index 2d82a45b..cb1c6e1b 100644 --- a/LEGO1/lego/legoomni/include/legoanimationmanager.h +++ b/LEGO1/lego/legoomni/include/legoanimationmanager.h @@ -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, diff --git a/LEGO1/lego/legoomni/include/legobuildingmanager.h b/LEGO1/lego/legoomni/include/legobuildingmanager.h index e9bb2778..d8422695 100644 --- a/LEGO1/lego/legoomni/include/legobuildingmanager.h +++ b/LEGO1/lego/legoomni/include/legobuildingmanager.h @@ -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; diff --git a/LEGO1/lego/legoomni/include/legocachsound.h b/LEGO1/lego/legoomni/include/legocachsound.h index cf5e1e2e..c563fc19 100644 --- a/LEGO1/lego/legoomni/include/legocachsound.h +++ b/LEGO1/lego/legoomni/include/legocachsound.h @@ -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); diff --git a/LEGO1/lego/legoomni/include/legoinputmanager.h b/LEGO1/lego/legoomni/include/legoinputmanager.h index bd31d37b..99615cb8 100644 --- a/LEGO1/lego/legoomni/include/legoinputmanager.h +++ b/LEGO1/lego/legoomni/include/legoinputmanager.h @@ -180,6 +180,7 @@ class LegoInputManager : public MxPresenter { // LegoInputManager::`scalar deleting destructor' private: + friend class Extensions::MultiplayerExt; friend class Extensions::ThirdPersonCameraExt; void InitializeHaptics(); diff --git a/LEGO1/lego/legoomni/include/legomodelpresenter.h b/LEGO1/lego/legoomni/include/legomodelpresenter.h index 950bdb7e..f7793ab1 100644 --- a/LEGO1/lego/legoomni/include/legomodelpresenter.h +++ b/LEGO1/lego/legoomni/include/legomodelpresenter.h @@ -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 diff --git a/LEGO1/lego/legoomni/include/legoplantmanager.h b/LEGO1/lego/legoomni/include/legoplantmanager.h index 187e12b5..9b3b1cd9 100644 --- a/LEGO1/lego/legoomni/include/legoplantmanager.h +++ b/LEGO1/lego/legoomni/include/legoplantmanager.h @@ -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); diff --git a/LEGO1/lego/legoomni/include/legoutils.h b/LEGO1/lego/legoomni/include/legoutils.h index 8f6e9d81..62029e34 100644 --- a/LEGO1/lego/legoomni/include/legoutils.h +++ b/LEGO1/lego/legoomni/include/legoutils.h @@ -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) diff --git a/LEGO1/lego/legoomni/src/actors/ambulance.cpp b/LEGO1/lego/legoomni/src/actors/ambulance.cpp index 40bb3142..3c9adb14 100644 --- a/LEGO1/lego/legoomni/src/actors/ambulance.cpp +++ b/LEGO1/lego/legoomni/src/actors/ambulance.cpp @@ -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 #include +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::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/bike.cpp b/LEGO1/lego/legoomni/src/actors/bike.cpp index e3414254..a4f7be85 100644 --- a/LEGO1/lego/legoomni/src/actors/bike.cpp +++ b/LEGO1/lego/legoomni/src/actors/bike.cpp @@ -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::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp b/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp index 03c97f7b..47a7e7f5 100644 --- a/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp +++ b/LEGO1/lego/legoomni/src/actors/dunebuggy.cpp @@ -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 #include +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::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/actors/isleactor.cpp b/LEGO1/lego/legoomni/src/actors/isleactor.cpp index 6f718b55..000adc62 100644 --- a/LEGO1/lego/legoomni/src/actors/isleactor.cpp +++ b/LEGO1/lego/legoomni/src/actors/isleactor.cpp @@ -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::Call(MP::HandleEntityNotify, (LegoEntity*) this).value_or(FALSE)) { + result = 1; + } + else { + result = HandleClick(); + } break; case c_notificationEndAnim: result = HandleEndAnim(); diff --git a/LEGO1/lego/legoomni/src/actors/towtrack.cpp b/LEGO1/lego/legoomni/src/actors/towtrack.cpp index 301c9d44..2d0e2bef 100644 --- a/LEGO1/lego/legoomni/src/actors/towtrack.cpp +++ b/LEGO1/lego/legoomni/src/actors/towtrack.cpp @@ -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 +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::Call(MP::HandleHornPressed, (MxU32) p_param.m_clickedObjectId); break; } } diff --git a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp index c30eb98e..aa9facbe 100644 --- a/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp +++ b/LEGO1/lego/legoomni/src/common/legocharactermanager.cpp @@ -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::Call(TP::IsClonedCharacter, p_name).value_or(FALSE)) { + !Extension::Call(TP::IsClonedCharacter, p_name).value_or(FALSE) && + !Extension::Call(MP::IsClonedCharacter, p_name).value_or(FALSE)) { LegoExtraActor* actor = new LegoExtraActor(); actor->SetROI(character->m_roi, FALSE, FALSE); diff --git a/LEGO1/lego/legoomni/src/common/legogamestate.cpp b/LEGO1/lego/legoomni/src/common/legogamestate.cpp index efdcbf8b..05d7adc6 100644 --- a/LEGO1/lego/legoomni/src/common/legogamestate.cpp +++ b/LEGO1/lego/legoomni/src/common/legogamestate.cpp @@ -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 #include +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::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::Call(MP::HandleSaveLoaded); + done: if (result != SUCCESS) { OmniError("Game state loading was not successful!", 0); diff --git a/LEGO1/lego/legoomni/src/common/legoutils.cpp b/LEGO1/lego/legoomni/src/common/legoutils.cpp index de055c26..ca2042d6 100644 --- a/LEGO1/lego/legoomni/src/common/legoutils.cpp +++ b/LEGO1/lego/legoomni/src/common/legoutils.cpp @@ -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); } diff --git a/LEGO1/lego/legoomni/src/entity/legoentity.cpp b/LEGO1/lego/legoomni/src/entity/legoentity.cpp index 1f9df08b..d73a5daf 100644 --- a/LEGO1/lego/legoomni/src/entity/legoentity.cpp +++ b/LEGO1/lego/legoomni/src/entity/legoentity.cpp @@ -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 +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::Call(MP::HandleEntityNotify, this).value_or(FALSE)) { + return 1; + } + switch (GameState()->GetActorId()) { case LegoActor::c_pepper: if (GameState()->GetCurrentAct() != LegoGameState::e_act2 && diff --git a/LEGO1/lego/legoomni/src/entity/legoworld.cpp b/LEGO1/lego/legoomni/src/entity/legoworld.cpp index 2ed53e22..ab141f38 100644 --- a/LEGO1/lego/legoomni/src/entity/legoworld.cpp +++ b/LEGO1/lego/legoomni/src/entity/legoworld.cpp @@ -1,6 +1,7 @@ #include "legoworld.h" #include "anim/legoanim.h" +#include "extensions/multiplayer.h" #include "extensions/siloader.h" #include "extensions/thirdpersoncamera.h" #include "legoanimationmanager.h" @@ -756,6 +757,7 @@ void LegoWorld::Enable(MxBool p_enable) #endif Extension::Call(TP::HandleWorldEnable, this, TRUE); + Extension::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::Call(TP::HandleWorldEnable, this, FALSE); + Extension::Call(MP::HandleWorldEnable, this, FALSE); } } diff --git a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp index e6498ce4..a3b3f076 100644 --- a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp +++ b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp @@ -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::Call(TP::HandleROIClick, roi, p_param).value_or(FALSE)) { + if (Extension::Call(MP::HandleROIClick, roi, p_param).value_or(FALSE) || + Extension::Call(TP::HandleROIClick, roi, p_param).value_or(FALSE)) { return TRUE; } } diff --git a/LEGO1/lego/legoomni/src/main/legomain.cpp b/LEGO1/lego/legoomni/src/main/legomain.cpp index ecb9c693..14c3b5be 100644 --- a/LEGO1/lego/legoomni/src/main/legomain.cpp +++ b/LEGO1/lego/legoomni/src/main/legomain.cpp @@ -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::Call(TP::HandleCreate); + Extension::Call(MP::HandleCreate); result = SUCCESS; } else { diff --git a/LEGO1/lego/legoomni/src/worlds/isle.cpp b/LEGO1/lego/legoomni/src/worlds/isle.cpp index 349c90a3..1c4fb2a7 100644 --- a/LEGO1/lego/legoomni/src/worlds/isle.cpp +++ b/LEGO1/lego/legoomni/src/worlds/isle.cpp @@ -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::Call(MP::HandleSkyLightControl, (MxU32) p_param.m_clickedObjectId) + .value_or(FALSE)) { + return 1; + } + MxDSAction action; switch (p_param.m_clickedObjectId) { diff --git a/LEGO1/lego/legoomni/src/worlds/registrationbook.cpp b/LEGO1/lego/legoomni/src/worlds/registrationbook.cpp index a3e068f0..b6dc1377 100644 --- a/LEGO1/lego/legoomni/src/worlds/registrationbook.cpp +++ b/LEGO1/lego/legoomni/src/worlds/registrationbook.cpp @@ -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); diff --git a/docker/relay/Dockerfile b/docker/relay/Dockerfile new file mode 100644 index 00000000..e23335a2 --- /dev/null +++ b/docker/relay/Dockerfile @@ -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"] diff --git a/extensions/docs/thirdpersoncamera/roi-direction-conventions.md b/extensions/docs/thirdpersoncamera/roi-direction-conventions.md new file mode 100644 index 00000000..2213ddfb --- /dev/null +++ b/extensions/docs/thirdpersoncamera/roi-direction-conventions.md @@ -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. diff --git a/extensions/include/extensions/common/characteranimator.h b/extensions/include/extensions/common/characteranimator.h index 1d7327a4..cf43b442 100644 --- a/extensions/include/extensions/common/characteranimator.h +++ b/extensions/include/extensions/common/characteranimator.h @@ -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); diff --git a/extensions/include/extensions/extensions.h b/extensions/include/extensions/extensions.h index 26f768b1..98b23af3 100644 --- a/extensions/include/extensions/extensions.h +++ b/extensions/include/extensions/extensions.h @@ -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 p_options); diff --git a/extensions/include/extensions/fwd.h b/extensions/include/extensions/fwd.h index 8e987fbd..c839ad3e 100644 --- a/extensions/include/extensions/fwd.h +++ b/extensions/include/extensions/fwd.h @@ -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 diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h new file mode 100644 index 00000000..62b76bcd --- /dev/null +++ b/extensions/include/extensions/multiplayer.h @@ -0,0 +1,92 @@ +#pragma once + +#include "extensions/extensions.h" +#include "mxtypes.h" + +#include +#include + +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 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 diff --git a/extensions/include/extensions/multiplayer/animation/audioplayer.h b/extensions/include/extensions/multiplayer/animation/audioplayer.h new file mode 100644 index 00000000..6154ce9c --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/audioplayer.h @@ -0,0 +1,33 @@ +#pragma once + +#include "extensions/multiplayer/animation/loader.h" + +#include +#include + +class LegoCacheSound; + +namespace Multiplayer::Animation +{ + +class AudioPlayer { +public: + // Create LegoCacheSound objects from SceneAnimData's audio tracks + void Init(const std::vector& 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 m_activeSounds; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/catalog.h b/extensions/include/extensions/multiplayer/animation/catalog.h new file mode 100644 index 00000000..20f55615 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/catalog.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include + +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 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 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 m_entries; + std::map> m_locationIndex; // location ID → indices into m_entries + std::vector m_worldData; + std::vector m_modelROIs; // keep model ROIs alive to preserve LOD refcounts +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/coordinator.h b/extensions/include/extensions/multiplayer/animation/coordinator.h new file mode 100644 index 00000000..ed6e4797 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/coordinator.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include + +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 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 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 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& 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 m_sessions; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/loader.h b/extensions/include/extensions/multiplayer/animation/loader.h new file mode 100644 index 00000000..d5e0c341 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/loader.h @@ -0,0 +1,122 @@ +#pragma once + +#include "extensions/multiplayer/sireader.h" +#include "mxcriticalsection.h" +#include "mxthread.h" +#include "mxwavepresenter.h" + +#include +#include +#include +#include +#include + +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 audioTracks; + + struct PhonemeTrack { + FLIC_HEADER* flcHeader; + std::vector> frameData; + uint32_t timeOffset; + std::string roiName; + uint16_t width, height; + }; + std::vector phonemeTracks; + + // Action transform from SI metadata (location/direction/up) + struct { + float location[3]; + float direction[3]; + float up[3]; + bool valid; + } actionTransform; + + std::vector 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 m_extraSI; // SI handles for non-act1 worlds + std::map m_cache; // keyed by CacheKey(worldId, objectId) + MxCriticalSection m_cacheCS; + + PreloadThread* m_preloadThread; + int8_t m_preloadWorldId; + uint32_t m_preloadObjectId; + std::atomic m_preloadDone; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/locationproximity.h b/extensions/include/extensions/multiplayer/animation/locationproximity.h new file mode 100644 index 00000000..e45fdd5b --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/locationproximity.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +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& 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 ComputeAll(float p_x, float p_z, float p_radius); + +private: + float m_radius; + std::vector m_locations; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/phonemeplayer.h b/extensions/include/extensions/multiplayer/animation/phonemeplayer.h new file mode 100644 index 00000000..de60bcdf --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/phonemeplayer.h @@ -0,0 +1,39 @@ +#pragma once + +#include "extensions/multiplayer/animation/loader.h" + +#include +#include + +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& p_tracks, + LegoROI** p_roiMap, + MxU32 p_roiMapSize, + const std::vector>& p_actorAliases + ); + void Tick(float p_elapsedMs, const std::vector& p_tracks); + void Cleanup(); + void NotifyROIDestroyed(LegoROI* p_roi); + +private: + std::vector m_states; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/sceneplayer.h b/extensions/include/extensions/multiplayer/animation/sceneplayer.h new file mode 100644 index 00000000..8aeb4689 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sceneplayer.h @@ -0,0 +1,106 @@ +#pragma once + +#include "extensions/multiplayer/animation/audioplayer.h" +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/loader.h" +#include "extensions/multiplayer/animation/phonemeplayer.h" +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include +#include +#include + +class LegoROI; +struct AnimInfo; + +namespace Multiplayer::Animation +{ + +// A participant (local or remote player) whose ROI is borrowed during animation +struct ParticipantROI { + LegoROI* roi; + LegoROI* vehicleROI; // Ride vehicle ROI (bike/board/moto), or nullptr + MxMatrix savedTransform; + std::string savedName; + int8_t charIndex; // g_actorInfoInit[] index, or -1 for spectator + + bool IsSpectator() const { return charIndex < 0; } +}; + +class ScenePlayer { +public: + ScenePlayer(); + ~ScenePlayer(); + + // When p_observerMode is false, p_participants[0] must be the local player. + // When p_observerMode is true, participants are only remote performers (no local player). + void Play( + const AnimInfo* p_animInfo, + int8_t p_worldId, + AnimCategory p_category, + const ParticipantROI* p_participants, + uint8_t p_participantCount, + bool p_observerMode = false + ); + void Tick(); + void Stop(); + void NotifyROIDestroyed(LegoROI* p_roi); + bool IsPlaying() const { return m_playing; } + bool IsObserverMode() const { return m_observerMode; } + + void SetLoader(Loader* p_loader) { m_loader = p_loader; } + +private: + void ComputeRebaseMatrix(); + void SetupROIs(const AnimInfo* p_animInfo); + void ResolvePtAtCamROIs(); + void ApplyPtAtCam(); + void CleanupProps(); + + // Sub-components + Loader* m_loader; + AudioPlayer m_audioPlayer; + PhonemePlayer m_phonemePlayer; + + // Playback state + bool m_playing; + bool m_rebaseComputed; + uint64_t m_startTime; + SceneAnimData* m_currentData; + AnimCategory m_category; + MxMatrix m_animPose0; + MxMatrix m_rebaseMatrix; + + // Participants (local player at index 0, remote players after) + std::vector 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> m_actorAliases; + + // Props created for the animation (cloned characters and prop models) + std::vector m_propROIs; + + // ROIs cloned from scene (created by sharing LOD data, not registered in CharacterManager) + std::vector m_clonedSceneROIs; + + bool m_hasCamAnim; + bool m_observerMode; + std::vector m_ptAtCamROIs; + bool m_hideOnStop; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/sessionhost.h b/extensions/include/extensions/multiplayer/animation/sessionhost.h new file mode 100644 index 00000000..ae43080b --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sessionhost.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +namespace Multiplayer::Animation +{ + +class Catalog; +struct CatalogEntry; +enum class CoordinationState : uint8_t; + +struct SessionSlot { + uint32_t peerId; // 0 = unfilled + int8_t charIndex; // g_actorInfoInit index, or -1 for spectator + + bool IsSpectator() const { return IsSpectatorCharIndex(charIndex); } + static bool IsSpectatorCharIndex(int8_t p_charIndex) { return p_charIndex < 0; } +}; + +struct AnimSession { + uint16_t animIndex; + CoordinationState state; + std::vector 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& p_changedAnims + ); + bool HandleCancel(uint32_t p_peerId, std::vector& p_changedAnims); + bool HandlePlayerRemoved(uint32_t p_peerId, std::vector& p_changedAnims); + + // Returns animIndices of all sessions ready to play + std::vector 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& 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 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& p_changedAnims); + void RemovePlayerFromSessions( + uint32_t p_peerId, + bool p_includePlayingSessions, + std::vector& p_changedAnims + ); + + const Catalog* m_catalog = nullptr; + std::map m_sessions; + + static const uint32_t COUNTDOWN_DURATION_MS = 4000; +}; + +} // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/emoteanimhandler.h b/extensions/include/extensions/multiplayer/emoteanimhandler.h new file mode 100644 index 00000000..dfc98cdf --- /dev/null +++ b/extensions/include/extensions/multiplayer/emoteanimhandler.h @@ -0,0 +1,59 @@ +#pragma once + +#include "extensions/common/characteranimator.h" + +#include + +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 diff --git a/extensions/include/extensions/multiplayer/mputils.h b/extensions/include/extensions/multiplayer/mputils.h new file mode 100644 index 00000000..79f13865 --- /dev/null +++ b/extensions/include/extensions/multiplayer/mputils.h @@ -0,0 +1,47 @@ +#pragma once + +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include +#include +#include + +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 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& 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 diff --git a/extensions/include/extensions/multiplayer/namebubblerenderer.h b/extensions/include/extensions/multiplayer/namebubblerenderer.h new file mode 100644 index 00000000..27984a03 --- /dev/null +++ b/extensions/include/extensions/multiplayer/namebubblerenderer.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +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 diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h new file mode 100644 index 00000000..340b79bb --- /dev/null +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -0,0 +1,257 @@ +#pragma once + +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/coordinator.h" +#include "extensions/multiplayer/animation/locationproximity.h" +#include "extensions/multiplayer/animation/sceneplayer.h" +#include "extensions/multiplayer/animation/sessionhost.h" +#include "extensions/multiplayer/networktransport.h" +#include "extensions/multiplayer/platformcallbacks.h" +#include "extensions/multiplayer/protocol.h" +#include "extensions/multiplayer/remoteplayer.h" +#include "extensions/multiplayer/sireader.h" +#include "extensions/multiplayer/worldstatesync.h" +#include "mxcore.h" +#include "mxtypes.h" + +#include +#include +#include +#include +#include +#include + +class LegoEntity; +class LegoWorld; + +namespace Extensions +{ +class ThirdPersonCameraExt; +} + +namespace Multiplayer +{ + +class NameBubbleRenderer; + +class NetworkManager : public MxCore { +public: + enum ConnectionState { + STATE_DISCONNECTED, + STATE_CONNECTED, + STATE_RECONNECTING + }; + + NetworkManager(); + ~NetworkManager() override; + + MxResult Tickle() override; + + const char* ClassName() const override { return "NetworkManager"; } + + MxBool IsA(const char* p_name) const override + { + return !SDL_strcmp(p_name, NetworkManager::ClassName()) || MxCore::IsA(p_name); + } + + void Initialize(NetworkTransport* p_transport, PlatformCallbacks* p_callbacks); + void HandleCreate(); + void Shutdown(); + + void Connect(const char* p_roomId); + void Disconnect(); + bool IsConnected() const; + bool WasRejected() const; + + void SetWalkAnimation(uint8_t p_walkAnimId); + void SetIdleAnimation(uint8_t p_idleAnimId); + void SendEmote(uint8_t p_emoteId); + void SendHorn(int8_t p_vehicleType); + + // Thread-safe request methods for cross-thread callers (e.g. WASM exports + // running on the browser main thread). Deferred to the game thread in Tickle(). + void RequestToggleThirdPerson() { m_pendingToggleThirdPerson.store(true, std::memory_order_relaxed); } + void RequestSetWalkAnimation(uint8_t p_walkAnimId) + { + m_pendingWalkAnim.store(p_walkAnimId, std::memory_order_relaxed); + } + void RequestSetIdleAnimation(uint8_t p_idleAnimId) + { + m_pendingIdleAnim.store(p_idleAnimId, std::memory_order_relaxed); + } + void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); } + void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); } + void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.store(true, std::memory_order_relaxed); } + void RequestSetAnimInterest(int32_t p_animIndex) + { + m_pendingAnimInterest.store(p_animIndex, std::memory_order_relaxed); + } + void RequestCancelAnimInterest() { m_pendingAnimCancel.store(true, std::memory_order_relaxed); } + + bool IsInIsleWorld() const { return m_inIsleWorld; } + + RemotePlayer* FindPlayerByROI(LegoROI* p_roi) const; + bool IsClonedCharacter(const char* p_name) const; + void SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex); + + // Stop any playing animation and release its resources. + // Must be called before the display ROI is destroyed. + void StopAnimation(); + + void OnWorldEnabled(LegoWorld* p_world); + void OnWorldDisabled(LegoWorld* p_world); + void OnBeforeSaveLoad(); + void OnSaveLoaded(); + + void NotifyThirdPersonChanged(bool p_enabled); + void NotifyNameBubblesChanged(bool p_enabled); + void NotifyAllowCustomizeChanged(bool p_enabled); + + // Called from multiplayer extension when a plant/building entity is clicked. + // Returns TRUE if the mutation should be suppressed locally (non-host). + MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType); + + // Called from multiplayer extension when a sky/light control is used. + // Returns TRUE if the local action should be suppressed (non-host). + MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType); + + bool IsHost() const { return m_localPeerId != 0 && m_localPeerId == m_hostPeerId; } + uint32_t GetLocalPeerId() const { return m_localPeerId; } + +private: + void BroadcastLocalState(); + void ProcessIncomingPackets(); + void UpdateRemotePlayers(float p_deltaTime); + + RemotePlayer* CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex); + + void HandleLeave(const PlayerLeaveMsg& p_msg); + void HandleState(const PlayerStateMsg& p_msg); + void HandleHostAssign(const HostAssignMsg& p_msg); + void HandleEmote(const EmoteMsg& p_msg); + void HandleHorn(const HornMsg& p_msg); + void HandleCustomize(const CustomizeMsg& p_msg); + + // Animation coordination handlers + void HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex); + void HandleAnimCancel(uint32_t p_peerId); + void HandleAnimUpdate(const AnimUpdateMsg& p_msg); + void HandleAnimStart(const AnimStartMsg& p_msg); + void HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession, uint64_t p_eventId); + AnimUpdateMsg BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target); + void BroadcastAnimUpdate(uint16_t p_animIndex); + void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId); + void BroadcastAnimStart(uint16_t p_animIndex, uint64_t p_eventId); + bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const; + bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; + bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; + uint8_t GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const; + bool ValidateSessionLocations(uint16_t p_animIndex); + + void ResetAnimationState(); + void CancelLocalAnimInterest(); + void BroadcastChangedSessions(const std::vector& 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 + void SendMessage(const T& p_msg); + + NetworkTransport* m_transport; + PlatformCallbacks* m_callbacks; + WorldStateSync m_worldSync; + NameBubbleRenderer* m_localNameBubble; + std::map> m_remotePlayers; + std::map 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 m_pendingToggleThirdPerson; + std::atomic m_pendingToggleNameBubbles; + std::atomic m_pendingWalkAnim; + std::atomic m_pendingIdleAnim; + std::atomic m_pendingEmote; + std::atomic m_pendingToggleAllowCustomize; + std::atomic m_pendingAnimInterest; + std::atomic 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> m_playingAnims; + + // Pre-built completion JSON per playing animation (non-observer participants only). + // Cached at animation start so it survives host migration/dropout. + std::map m_pendingCompletionJson; + std::string BuildCompletionJson(uint16_t p_animIndex, uint64_t p_eventId); + + void TickAnimation(); + void StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes); + void StopAllPlayback(); + void NotifyAnimationsROIDestroyed(RemotePlayer* p_player); + void UnlockRemotesForAnim(uint16_t p_animIndex); + + // Horn sound synchronization + void PreloadHornSounds(); + void CleanupHornSounds(); + + // Animation state push + bool m_animStateDirty; + bool m_animInterestDirty; + uint32_t m_lastAnimPushTime; + + ConnectionState m_connectionState; + bool m_wasRejected; + std::string m_roomId; + uint32_t m_reconnectAttempt; + uint32_t m_reconnectDelay; + uint32_t m_nextReconnectTime; + + static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz + static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout + static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000; + static const uint32_t RECONNECT_MAX_DELAY_MS = 30000; + static const uint32_t RECONNECT_MAX_ATTEMPTS = 10; + static const uint32_t ANIM_PUSH_COOLDOWN_MS = 250; // max ~4Hz for movement-based changes + + // Horn sound data + static const int HORN_VEHICLE_COUNT = 4; + class LegoCacheSound* m_hornTemplates[HORN_VEHICLE_COUNT]; + std::vector m_activeHorns; +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/networktransport.h b/extensions/include/extensions/multiplayer/networktransport.h new file mode 100644 index 00000000..022969e8 --- /dev/null +++ b/extensions/include/extensions/multiplayer/networktransport.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace Multiplayer +{ + +class NetworkTransport { +public: + virtual ~NetworkTransport() = default; + + virtual void Connect(const char* p_roomId) = 0; + virtual void Disconnect() = 0; + virtual bool IsConnected() const = 0; + 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 p_callback) = 0; +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h new file mode 100644 index 00000000..18cf610c --- /dev/null +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +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 diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h new file mode 100644 index 00000000..cb387f13 --- /dev/null +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -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__ diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/websockettransport.h b/extensions/include/extensions/multiplayer/platforms/emscripten/websockettransport.h new file mode 100644 index 00000000..88386ba3 --- /dev/null +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/websockettransport.h @@ -0,0 +1,36 @@ +#pragma once + +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer/networktransport.h" + +#include + +namespace Multiplayer +{ + +class WebSocketTransport : public NetworkTransport { +public: + WebSocketTransport(const std::string& p_relayBaseUrl); + ~WebSocketTransport() override; + + void Connect(const char* p_roomId) override; + void Disconnect() override; + bool IsConnected() const override; + 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 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__ diff --git a/extensions/include/extensions/multiplayer/platforms/native/lwstransport.h b/extensions/include/extensions/multiplayer/platforms/native/lwstransport.h new file mode 100644 index 00000000..fc70682b --- /dev/null +++ b/extensions/include/extensions/multiplayer/platforms/native/lwstransport.h @@ -0,0 +1,72 @@ +#pragma once + +#ifndef __EMSCRIPTEN__ + +#include "extensions/multiplayer/networktransport.h" +#include "mxcriticalsection.h" +#include "mxthread.h" + +#include +#include +#include +#include + +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 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 m_wsi; + std::atomic m_connected; + std::atomic m_disconnected; + std::atomic m_wasEverConnected; + + MxCriticalSection m_sendCS; + MxCriticalSection m_recvCS; + std::deque> m_sendQueue; + std::deque> m_recvQueue; + std::vector m_fragment; + + LwsServiceThread* m_serviceThread; + std::atomic m_wantWritable; +}; + +} // namespace Multiplayer + +#endif // !__EMSCRIPTEN__ diff --git a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h new file mode 100644 index 00000000..1a429a9a --- /dev/null +++ b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h @@ -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__ diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h new file mode 100644 index 00000000..1acf4c83 --- /dev/null +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -0,0 +1,266 @@ +#pragma once + +#include "extensions/common/constants.h" +#include "extensions/multiplayer/networktransport.h" + +#include +#include +#include +#include + +namespace Multiplayer +{ + +static constexpr size_t USERNAME_BUFFER_SIZE = 8; // 7 chars + null terminator + +// Routing target constants for MessageHeader.target +const uint32_t TARGET_BROADCAST = 0; // Broadcast to all except sender +const uint32_t TARGET_HOST = 0xFFFFFFFF; // Send to host only +const uint32_t TARGET_BROADCAST_ALL = 0xFFFFFFFE; // Broadcast to all including sender + +enum MessageType : uint8_t { + MSG_LEAVE = 2, + MSG_STATE = 3, + MSG_HOST_ASSIGN = 4, + MSG_REQUEST_SNAPSHOT = 5, + MSG_WORLD_SNAPSHOT = 6, + MSG_WORLD_EVENT = 7, + MSG_WORLD_EVENT_REQUEST = 8, + MSG_EMOTE = 9, + MSG_CUSTOMIZE = 10, + MSG_ANIM_INTEREST = 11, + MSG_ANIM_CANCEL = 12, + MSG_ANIM_UPDATE = 13, + MSG_ANIM_START = 14, + MSG_HORN = 16, + MSG_ASSIGN_ID = 0xFF +}; + +using Extensions::Common::VEHICLE_AMBULANCE; +using Extensions::Common::VEHICLE_BIKE; +using Extensions::Common::VEHICLE_COUNT; +using Extensions::Common::VEHICLE_DUNEBUGGY; +using Extensions::Common::VEHICLE_HELICOPTER; +using Extensions::Common::VEHICLE_JETSKI; +using Extensions::Common::VEHICLE_MOTOCYCLE; +using Extensions::Common::VEHICLE_NONE; +using Extensions::Common::VEHICLE_SKATEBOARD; +using Extensions::Common::VEHICLE_TOWTRACK; +using Extensions::Common::VehicleType; + +// Entity types for world events +enum WorldEntityType : uint8_t { + ENTITY_PLANT = 0, + ENTITY_BUILDING = 1, + ENTITY_SKY = 2, + ENTITY_LIGHT = 3 +}; + +using Extensions::Common::CHANGE_COLOR; +using Extensions::Common::CHANGE_DECREMENT; +using Extensions::Common::CHANGE_MOOD; +using Extensions::Common::CHANGE_MOVE; +using Extensions::Common::CHANGE_SOUND; +using Extensions::Common::CHANGE_VARIANT; +using Extensions::Common::WorldChangeType; + +// Change types for ENTITY_SKY +enum SkyChangeType : uint8_t { + SKY_TOGGLE_COLOR = 0, + SKY_DAY = 1, + SKY_NIGHT = 2 +}; + +// Change types for ENTITY_LIGHT +enum LightChangeType : uint8_t { + LIGHT_INCREMENT = 0, + LIGHT_DECREMENT = 1 +}; + +#pragma pack(push, 1) + +struct MessageHeader { + uint8_t type; + uint8_t _pad; + uint32_t peerId; + uint32_t sequence; + uint32_t target; +}; + +struct PlayerLeaveMsg { + MessageHeader header; +}; + +struct PlayerStateMsg { + MessageHeader header; + uint8_t actorId; + int8_t worldId; + int8_t vehicleType; + float position[3]; + float direction[3]; + float up[3]; + float speed; + uint8_t walkAnimId; // Index into walk animation table (0 = default) + uint8_t idleAnimId; // Index into idle animation table (0 = default) + char name[USERNAME_BUFFER_SIZE]; // Player display name (7 chars + null terminator) + uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) + uint8_t customizeData[5]; // Packed CustomizeState + uint8_t customizeFlags; // Bit 0 = allowRemoteCustomize +}; + +// Server -> all: announces which peer is the host +struct HostAssignMsg { + MessageHeader header; + uint32_t hostPeerId; +}; + +// Client -> host: request full world state snapshot +struct RequestSnapshotMsg { + MessageHeader header; +}; + +// Host -> specific client: full world state blob (variable length) +// Relay reads header.target and routes to that peer only. +struct WorldSnapshotMsg { + MessageHeader header; + uint16_t dataLength; + // Followed by dataLength bytes of serialized plant + building state +}; + +// Host -> all: single world state mutation +struct WorldEventMsg { + MessageHeader header; + uint8_t entityType; // WorldEntityType + uint8_t changeType; // WorldChangeType + uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[] + uint8_t padding; // Alignment +}; + +// Non-host -> host: request a mutation (same layout as WorldEventMsg) +struct WorldEventRequestMsg { + MessageHeader header; + uint8_t entityType; // WorldEntityType + uint8_t changeType; // WorldChangeType + uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[] + uint8_t padding; // Alignment +}; + +// One-shot emote trigger, broadcast to all peers +struct EmoteMsg { + MessageHeader header; + uint8_t emoteId; // Index into emote table +}; + +// One-shot horn sound trigger, broadcast to all peers +struct HornMsg { + MessageHeader header; + uint8_t vehicleType; // VehicleType enum value +}; + +// Immediate customization change, broadcast to all peers +struct CustomizeMsg { + MessageHeader header; + uint32_t targetPeerId; // Who is being customized + uint8_t changeType; // WorldChangeType (VARIANT/SOUND/MOVE/COLOR/MOOD) + uint8_t partIndex; // Body part for color changes (0-9), 0xFF otherwise +}; + +// Client -> Host: express interest in an animation slot +struct AnimInterestMsg { + MessageHeader header; + uint16_t animIndex; + uint8_t displayActorIndex; +}; + +// Client -> Host: cancel interest in current animation +struct AnimCancelMsg { + MessageHeader header; +}; + +// Per-slot assignment in AnimUpdateMsg +struct AnimSlotAssignment { + uint32_t peerId; // 0 = unfilled +}; + +// Host -> All: authoritative session state update +struct AnimUpdateMsg { + MessageHeader header; + uint16_t animIndex; + uint8_t state; // CoordinationState (0=cleared, 1=gathering, 2=countdown, 3=playing) + uint16_t countdownMs; // Remaining countdown ms (0 if not counting) + uint8_t slotCount; // Number of valid slot entries + AnimSlotAssignment slots[8]; // peerId per slot (0 = unfilled) +}; + +// Host -> All: animation playback trigger +struct AnimStartMsg { + MessageHeader header; + uint16_t animIndex; + uint64_t eventId; // Random 64-bit ID for the completion event (host-generated, same for all clients) +}; + +#pragma pack(pop) + +// Bitmask constants for PlayerStateMsg::customizeFlags +static constexpr uint8_t CUSTOMIZE_FLAG_ALLOW_REMOTE = 0x01; +static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN = 0x02; +static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN_EMOTE_SHIFT = 2; +static constexpr uint8_t CUSTOMIZE_FLAG_FROZEN_EMOTE_MASK = 0x07; + +using Extensions::Common::IsValidActorId; + +// Convert LegoGameState::Username letter indices (0-25 = A-Z) to ASCII. +// Writes up to 7 characters + null terminator into p_out (must be at least 8 bytes). +void EncodeUsername(char p_out[USERNAME_BUFFER_SIZE]); + +using Extensions::Common::DISPLAY_ACTOR_NONE; + +// Parse the message type from a buffer. Returns MSG type or 0 on error. +inline uint8_t ParseMessageType(const uint8_t* p_data, size_t p_length) +{ + if (p_length < 1) { + return 0; + } + return p_data[0]; +} + +// Generic serialization: copy a packed message struct into a buffer. +template +inline size_t SerializeMsg(uint8_t* p_buf, size_t p_bufLen, const T& p_msg) +{ + static_assert(std::is_trivially_copyable_v); + 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 +inline bool DeserializeMsg(const uint8_t* p_data, size_t p_length, T& p_out) +{ + static_assert(std::is_trivially_copyable_v); + 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 +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 diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h new file mode 100644 index 00000000..2ce036d5 --- /dev/null +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -0,0 +1,126 @@ +#pragma once + +#include "extensions/common/characteranimator.h" +#include "extensions/common/customizestate.h" +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/emoteanimhandler.h" +#include "extensions/multiplayer/protocol.h" +#include "mxgeometry/mxmatrix.h" +#include "mxtypes.h" + +#include +#include +#include + +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& GetLocations() const { return m_locations; } + void SetLocations(std::vector p_locations) { m_locations = std::move(p_locations); } + bool IsAtLocation(int16_t p_location) const; + bool HasReceivedUpdate() const { return m_hasReceivedUpdate; } + void GetTargetPosition(float& p_x, float& p_z) const + { + p_x = m_targetPosition[0]; + p_z = m_targetPosition[2]; + } + uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } + void SetVisible(bool p_visible); + void TriggerExtraAnim(uint8_t p_emoteId); + void SetNameBubbleVisible(bool p_visible); + void CreateNameBubble(); + void DestroyNameBubble(); + + const Extensions::Common::CustomizeState& GetCustomizeState() const { return m_customizeState; } + bool GetAllowRemoteCustomize() const { return m_allowRemoteCustomize; } + void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); } + void StopClickAnimation(); + bool IsInVehicle() const { return m_animator.IsInVehicle(); } + LegoROI* GetRideVehicleROI() const { return m_animator.GetRideVehicleROI(); } + bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; } + bool IsExtraAnimBlocking() const { return m_animator.IsExtraAnimBlocking(); } + + const char* GetDisplayName() const { return m_displayName; } + + void LockForAnimation(uint16_t p_lockedForAnimIndex) { m_lockedForAnimIndex = p_lockedForAnimIndex; } + void UnlockFromAnimation(uint16_t p_lockedForAnimIndex) + { + if (m_lockedForAnimIndex == p_lockedForAnimIndex) { + m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE; + } + } + void ForceUnlockAnimation() { m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE; } + bool IsAnimationLocked() const { return m_lockedForAnimIndex != Animation::ANIM_INDEX_NONE; } + +private: + bool IsEffectivelyMoving() const; + const char* GetDisplayActorName() const; + void UpdateTransform(float p_deltaTime); + void UpdateVehicleState(); + void EnterVehicle(int8_t p_vehicleType); + void ExitVehicle(); + + uint32_t m_peerId; + uint8_t m_actorId; + uint8_t m_displayActorIndex; + char m_uniqueName[32]; + char m_displayName[USERNAME_BUFFER_SIZE]; + + LegoROI* m_roi; + bool m_spawned; + bool m_visible; + + float m_targetPosition[3]; + float m_targetDirection[3]; + float m_targetUp[3]; + float m_targetSpeed; + int8_t m_targetVehicleType; + int8_t m_targetWorldId; + uint32_t m_lastUpdateTime; + bool m_hasReceivedUpdate; + std::vector 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 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 diff --git a/extensions/include/extensions/multiplayer/sireader.h b/extensions/include/extensions/multiplayer/sireader.h new file mode 100644 index 00000000..e56bd7a9 --- /dev/null +++ b/extensions/include/extensions/multiplayer/sireader.h @@ -0,0 +1,66 @@ +#pragma once + +#include "mxcriticalsection.h" +#include "mxwavepresenter.h" + +#include +#include + +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 diff --git a/extensions/include/extensions/multiplayer/worldstatesync.h b/extensions/include/extensions/multiplayer/worldstatesync.h new file mode 100644 index 00000000..5f53f30a --- /dev/null +++ b/extensions/include/extensions/multiplayer/worldstatesync.h @@ -0,0 +1,78 @@ +#pragma once + +#include "extensions/multiplayer/networktransport.h" +#include "extensions/multiplayer/protocol.h" +#include "mxtypes.h" + +#include +#include +#include + +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 + 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 m_pendingWorldEvents; + + // Saved sky/light state for non-host restore across save loads. + std::string m_savedSkyColor; + int m_savedLightPos; +}; + +} // namespace Multiplayer diff --git a/extensions/include/extensions/siloader.h b/extensions/include/extensions/siloader.h index 3e727ecd..5a06074d 100644 --- a/extensions/include/extensions/siloader.h +++ b/extensions/include/extensions/siloader.h @@ -21,10 +21,10 @@ class SiLoaderExt { static void Initialize(); static bool Load(); - static std::optional HandleFind(StreamObject p_object, LegoWorld* world); + static std::optional HandleFind(StreamObject p_object, LegoWorld* p_world); static std::optional HandleStart(MxDSAction& p_action); static MxBool HandleWorld(LegoWorld* p_world); - static std::optional HandleRemove(StreamObject p_object, LegoWorld* world); + static std::optional HandleRemove(StreamObject p_object, LegoWorld* p_world); static std::optional HandleDelete(MxDSAction& p_action); static MxBool HandleEndAction(MxEndActionNotificationParam& p_param); diff --git a/extensions/include/extensions/thirdpersoncamera/controller.h b/extensions/include/extensions/thirdpersoncamera/controller.h index eb0fca73..dd4b8989 100644 --- a/extensions/include/extensions/thirdpersoncamera/controller.h +++ b/extensions/include/extensions/thirdpersoncamera/controller.h @@ -62,19 +62,20 @@ class Controller { // Signal that an external animation is active. // p_lockDisplay: true if the display ROI is being driven by the animation (performer), - // false if just spectating (idle anim continues). + // false if just spectating (free to move). // p_onStop is called before the display ROI is destroyed (Deactivate/OnWorldDisabled). void SetAnimPlaying( bool p_animPlaying, - bool p_lockDisplay = true, + bool p_animLockDisplay = true, std::function p_animStopCallback = nullptr ) { m_animPlaying = p_animPlaying; - m_animLockDisplay = p_animPlaying && p_lockDisplay; + m_animLockDisplay = p_animPlaying && p_animLockDisplay; m_animStopCallback = p_animPlaying ? std::move(p_animStopCallback) : nullptr; } bool IsAnimPlaying() const { return m_animPlaying; } + bool IsAnimLockDisplay() const { return m_animLockDisplay; } void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -95,7 +96,7 @@ class Controller { bool IsLeftButtonHeld() const { return m_input.IsLeftButtonHeld(); } bool IsLmbForwardEngaged() const { return m_lmbForwardEngaged; } - void SetLmbForwardEngaged(bool p_engaged) { m_lmbForwardEngaged = p_engaged; } + void SetLmbForwardEngaged(bool p_lmbForwardEngaged) { m_lmbForwardEngaged = p_lmbForwardEngaged; } MxBool HandleFirstPersonForward( LegoNavController* p_nav, @@ -111,11 +112,11 @@ class Controller { void ResetTouchState() { m_input.ResetTouchState(); } void SuppressGestures() { m_input.SuppressGestures(); } - bool TryClaimFinger(const SDL_TouchFingerEvent& event) { return m_input.TryClaimFinger(event); } - bool TryReleaseFinger(SDL_FingerID id) { return m_input.TryReleaseFinger(id); } - bool IsFingerTracked(SDL_FingerID id) const { return m_input.IsFingerTracked(id); } + bool TryClaimFinger(const SDL_TouchFingerEvent& p_event) { return m_input.TryClaimFinger(p_event); } + bool TryReleaseFinger(SDL_FingerID p_id) { return m_input.TryReleaseFinger(p_id); } + bool IsFingerTracked(SDL_FingerID p_id) const { return m_input.IsFingerTracked(p_id); } int GetTouchCount() const { return m_input.GetTouchCount(); } - SDL_FingerID GetFingerID(int idx) const { return m_input.GetFingerID(idx); } + SDL_FingerID GetFingerID(int p_idx) const { return m_input.GetFingerID(p_idx); } void FreezeDisplayActor() { m_display.FreezeDisplayActor(); } void UnfreezeDisplayActor() { m_display.UnfreezeDisplayActor(); } @@ -129,7 +130,7 @@ class Controller { private: void CancelExternalAnim(); void Deactivate(); - void ReinitForCharacter(); + void ReinitForCharacter(bool p_preserveCamera = false); OrbitCamera m_orbit; InputHandler m_input; diff --git a/extensions/include/extensions/thirdpersoncamera/orbitcamera.h b/extensions/include/extensions/thirdpersoncamera/orbitcamera.h index d4ae2f0a..c8635a0d 100644 --- a/extensions/include/extensions/thirdpersoncamera/orbitcamera.h +++ b/extensions/include/extensions/thirdpersoncamera/orbitcamera.h @@ -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; diff --git a/extensions/src/common/characteranimator.cpp b/extensions/src/common/characteranimator.cpp index 0f27dab4..18dc632e 100644 --- a/extensions/src/common/characteranimator.cpp +++ b/extensions/src/common/characteranimator.cpp @@ -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; diff --git a/extensions/src/common/charactercloner.cpp b/extensions/src/common/charactercloner.cpp index a75036b2..b69e2063 100644 --- a/extensions/src/common/charactercloner.cpp +++ b/extensions/src/common/charactercloner.cpp @@ -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; diff --git a/extensions/src/common/charactercustomizer.cpp b/extensions/src/common/charactercustomizer.cpp index 680f3b79..fac3549c 100644 --- a/extensions/src/common/charactercustomizer.cpp +++ b/extensions/src/common/charactercustomizer.cpp @@ -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]); diff --git a/extensions/src/common/charactertables.cpp b/extensions/src/common/charactertables.cpp index ed94c3b5..3d8863c0 100644 --- a/extensions/src/common/charactertables.cpp +++ b/extensions/src/common/charactertables.cpp @@ -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) diff --git a/extensions/src/common/pathutils.cpp b/extensions/src/common/pathutils.cpp index de923713..3be8debc 100644 --- a/extensions/src/common/pathutils.cpp +++ b/extensions/src/common/pathutils.cpp @@ -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; } diff --git a/extensions/src/extensions.cpp b/extensions/src/extensions.cpp index ceff4b94..e8fa731a 100644 --- a/extensions/src/extensions.cpp +++ b/extensions/src/extensions.cpp @@ -1,5 +1,6 @@ #include "extensions/extensions.h" +#include "extensions/multiplayer.h" #include "extensions/siloader.h" #include "extensions/textureloader.h" #include "extensions/thirdpersoncamera.h" @@ -29,9 +30,16 @@ static void InitThirdPersonCamera(std::map p_options) ThirdPersonCameraExt::Initialize(); } +static void InitMultiplayer(std::map p_options) +{ + MultiplayerExt::options = std::move(p_options); + MultiplayerExt::enabled = true; + MultiplayerExt::Initialize(); +} + using InitFn = void (*)(std::map); -static const InitFn extensionInits[] = {InitTextureLoader, InitSiLoader, InitThirdPersonCamera}; +static const InitFn extensionInits[] = {InitTextureLoader, InitSiLoader, InitThirdPersonCamera, InitMultiplayer}; void Extensions::Enable(const char* p_key, std::map p_options) { diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp new file mode 100644 index 00000000..16a97433 --- /dev/null +++ b/extensions/src/multiplayer.cpp @@ -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 + +#ifdef __EMSCRIPTEN__ +#include "extensions/multiplayer/platforms/emscripten/callbacks.h" +#include "extensions/multiplayer/platforms/emscripten/websockettransport.h" + +#include +#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 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(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::Call(MP::CheckRejected).value_or(FALSE); +} diff --git a/extensions/src/multiplayer/animation/audioplayer.cpp b/extensions/src/multiplayer/animation/audioplayer.cpp new file mode 100644 index 00000000..807bf460 --- /dev/null +++ b/extensions/src/multiplayer/animation/audioplayer.cpp @@ -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& 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(); +} diff --git a/extensions/src/multiplayer/animation/catalog.cpp b/extensions/src/multiplayer/animation/catalog.cpp new file mode 100644 index 00000000..edba572b --- /dev/null +++ b/extensions/src/multiplayer/animation/catalog.cpp @@ -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 +#include +#include + +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 Multiplayer::Animation::GetPerformerIndices(uint64_t p_performerMask) +{ + std::vector 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(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 Catalog::GetAnimationsAtLocation(int16_t p_location) const +{ + std::vector 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 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); +} diff --git a/extensions/src/multiplayer/animation/coordinator.cpp b/extensions/src/multiplayer/animation/coordinator.cpp new file mode 100644 index 00000000..e8e195f8 --- /dev/null +++ b/extensions/src/multiplayer/animation/coordinator.cpp @@ -0,0 +1,304 @@ +#include "extensions/multiplayer/animation/coordinator.h" + +#include "extensions/multiplayer/animation/catalog.h" +#include "legoactors.h" + +#include + +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& 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 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 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(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& 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(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; +} diff --git a/extensions/src/multiplayer/animation/loader.cpp b/extensions/src/multiplayer/animation/loader.cpp new file mode 100644 index 00000000..67020eba --- /dev/null +++ b/extensions/src/multiplayer/animation/loader.cpp @@ -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 +#include + +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(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(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(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(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(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(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(); +} diff --git a/extensions/src/multiplayer/animation/locationproximity.cpp b/extensions/src/multiplayer/animation/locationproximity.cpp new file mode 100644 index 00000000..41fd83c4 --- /dev/null +++ b/extensions/src/multiplayer/animation/locationproximity.cpp @@ -0,0 +1,54 @@ +#include "extensions/multiplayer/animation/locationproximity.h" + +#include "decomp.h" +#include "legolocations.h" + +#include +#include + +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 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 LocationProximity::ComputeAll(float p_x, float p_z, float p_radius) +{ + std::vector 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(i)); + } + } + + // Sorted by index (iteration order is already ascending), which gives stable comparison + return result; +} diff --git a/extensions/src/multiplayer/animation/phonemeplayer.cpp b/extensions/src/multiplayer/animation/phonemeplayer.cpp new file mode 100644 index 00000000..af7b6273 --- /dev/null +++ b/extensions/src/multiplayer/animation/phonemeplayer.cpp @@ -0,0 +1,218 @@ +#include "extensions/multiplayer/animation/phonemeplayer.h" + +#include "extensions/multiplayer/animation/loader.h" +#include "flic.h" +#include "legocharactermanager.h" +#include "misc.h" +#include "misc/legocontainer.h" +#include "mxbitmap.h" +#include "roi/legoroi.h" + +#include + +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>& 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& p_tracks, + LegoROI** p_roiMap, + MxU32 p_roiMapSize, + const std::vector>& 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& p_tracks) +{ + for (size_t i = 0; i < p_tracks.size() && i < m_states.size(); i++) { + auto& track = p_tracks[i]; + auto& state = m_states[i]; + + if (!state.bitmap || !state.cachedTexture) { + continue; + } + + float trackElapsed = p_elapsedMs - (float) track.timeOffset; + if (trackElapsed < 0.0f) { + continue; + } + + if (track.flcHeader->speed == 0) { + continue; + } + + int targetFrame = (int) (trackElapsed / (float) track.flcHeader->speed); + if (targetFrame == state.currentFrame) { + continue; + } + if (targetFrame >= (int) track.frameData.size()) { + continue; + } + + int startFrame = state.currentFrame + 1; + if (startFrame < 0) { + startFrame = 0; + } + + for (int f = startFrame; f <= targetFrame; f++) { + const auto& data = track.frameData[f]; + if (data.size() < sizeof(MxS32)) { + continue; + } + + MxS32 rectCount; + SDL_memcpy(&rectCount, data.data(), sizeof(MxS32)); + size_t headerSize = sizeof(MxS32) + rectCount * sizeof(MxRect32); + if (data.size() <= headerSize) { + continue; + } + + FLIC_FRAME* flcFrame = (FLIC_FRAME*) (data.data() + headerSize); + + BYTE decodedColorMap; + DecodeFLCFrame( + &state.bitmap->GetBitmapInfo()->m_bmiHeader, + state.bitmap->GetImage(), + track.flcHeader, + flcFrame, + &decodedColorMap + ); + + // When the FLC frame updates the palette, apply it to the texture surface + if (decodedColorMap && state.cachedTexture->m_palette) { + PALETTEENTRY entries[256]; + RGBQUAD* colors = state.bitmap->GetBitmapInfo()->m_bmiColors; + for (int c = 0; c < 256; c++) { + entries[c].peRed = colors[c].rgbRed; + entries[c].peGreen = colors[c].rgbGreen; + entries[c].peBlue = colors[c].rgbBlue; + entries[c].peFlags = PC_NONE; + } + state.cachedTexture->m_palette->SetEntries(0, 0, 256, entries); + } + } + + state.cachedTexture->LoadBits(state.bitmap->GetImage()); + state.currentFrame = targetFrame; + } +} + +void PhonemePlayer::NotifyROIDestroyed(LegoROI* p_roi) +{ + for (auto& state : m_states) { + if (state.targetROI == p_roi) { + state.targetROI = nullptr; + } + } +} + +void PhonemePlayer::Cleanup() +{ + for (size_t i = 0; i < m_states.size(); i++) { + auto& state = m_states[i]; + + // Only the state that owns the original texture (i.e. performed the initial setup) + // should restore and erase. Other states sharing the same cachedTexture are secondary. + if (state.targetROI && state.originalTexture) { + CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture); + } + + if (state.originalTexture && state.cachedTexture) { + TextureContainer()->EraseCached(state.cachedTexture); + } + + delete state.bitmap; + } + m_states.clear(); +} diff --git a/extensions/src/multiplayer/animation/sceneplayer.cpp b/extensions/src/multiplayer/animation/sceneplayer.cpp new file mode 100644 index 00000000..7917528b --- /dev/null +++ b/extensions/src/multiplayer/animation/sceneplayer.cpp @@ -0,0 +1,691 @@ +#include "extensions/multiplayer/animation/sceneplayer.h" + +#include "3dmanager/lego3dmanager.h" +#include "anim/legoanim.h" +#include "extensions/common/animutils.h" +#include "extensions/common/charactercloner.h" +#include "extensions/multiplayer/mputils.h" +#include "legoactors.h" +#include "legoanimationmanager.h" +#include "legocameracontroller.h" +#include "legocharactermanager.h" +#include "legovideomanager.h" +#include "legoworld.h" +#include "misc.h" +#include "misc/legotree.h" +#include "mxbackgroundaudiomanager.h" +#include "mxgeometry/mxgeometry3d.h" +#include "realtime/realtime.h" +#include "roi/legoroi.h" +#include "viewmanager/viewmanager.h" + +#include +#include +#include +#include +#include +#include +#include + +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 createdROIs; + std::vector aliases; + std::deque aliasNames; + + std::vector participantMatched(m_participants.size(), false); + + static uint32_t s_roiCounter = 0; + + // Generate a unique ROI name to avoid collisions in the character manager + // and ViewManager. The counter is global so names stay unique across calls. + auto makeUniqueName = [](char* p_buf, size_t p_size, const char* p_prefix, const char* p_name) { + SDL_snprintf(p_buf, p_size, "%s_%s_%u", p_prefix, p_name, s_roiCounter++); + }; + + // Register an alias mapping an animation actor name to an ROI whose actual + // name differs (e.g. a participant's unique name, or a cloned scene ROI). + auto addAlias = [&](const std::string& p_name, LegoROI* p_roi) { + aliasNames.push_back(p_name); + aliases.push_back({aliasNames.back().c_str(), p_roi}); + m_actorAliases.push_back({p_name, p_roi}); + }; + + // Create a prop ROI from a registered LOD name. Returns nullptr if the + // LOD isn't in the ViewLODListManager. + auto createProp = [&](const std::string& p_name, const char* p_lodName) -> LegoROI* { + char uniqueName[64]; + makeUniqueName(uniqueName, sizeof(uniqueName), "npc_prop", p_name.c_str()); + LegoROI* roi = CharacterManager()->CreateAutoROI(uniqueName, p_lodName, FALSE); + if (roi) { + roi->SetName(p_name.c_str()); + createdROIs.push_back(roi); + } + return roi; + }; + + // Clone a scene ROI by name. Creates an independent deep copy (shared LOD + // geometry via refcount) with a unique name and an alias for the ROI map. + auto cloneSceneROI = [&](const std::string& p_name) -> LegoROI* { + const CompoundObject& sceneROIs = VideoManager()->Get3DManager()->GetLego3DView()->GetViewManager()->GetROIs(); + for (CompoundObject::const_iterator it = sceneROIs.begin(); it != sceneROIs.end(); it++) { + LegoROI* source = (LegoROI*) *it; + if (!source->GetName() || SDL_strcasecmp(source->GetName(), p_name.c_str())) { + continue; + } + + char uniqueName[64]; + makeUniqueName(uniqueName, sizeof(uniqueName), "npc_scene", p_name.c_str()); + + LegoROI* clone = Multiplayer::DeepCloneROI(source, uniqueName); + if (clone) { + clone->SetVisibility(FALSE); + VideoManager()->Get3DManager()->Add(*clone); + m_clonedSceneROIs.push_back(clone); + addAlias(p_name, clone); + } + return clone; + } + return nullptr; + }; + + for (LegoU32 i = 0; i < numActors; i++) { + const char* actorName = m_currentData->anim->GetActorName(i); + LegoU32 actorType = m_currentData->anim->GetActorType(i); + + if (!actorName || *actorName == '\0') { + continue; + } + + const char* lookupName = (*actorName == '*') ? actorName + 1 : actorName; + std::string lowered(lookupName); + std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower); + + if (actorType == LegoAnimActorEntry::e_managedLegoActor) { + // Character actor: match to a participant or clone as NPC + bool matched = false; + + for (size_t p = 0; p < m_participants.size(); p++) { + if (participantMatched[p] || m_participants[p].IsSpectator()) { + continue; + } + + if (MatchesCharacter(lowered, m_participants[p].charIndex)) { + participantMatched[p] = true; + matched = true; + addAlias(lowered, m_participants[p].roi); + break; + } + } + + if (!matched) { + char uniqueName[64]; + makeUniqueName(uniqueName, sizeof(uniqueName), "npc_char", lowered.c_str()); + LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str()); + if (roi) { + addAlias(lowered, roi); + VideoManager()->Get3DManager()->Add(*roi); + createdROIs.push_back(roi); + } + } + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoiTrimmed || actorType == LegoAnimActorEntry::e_sceneRoi1 || actorType == LegoAnimActorEntry::e_sceneRoi2) { + createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str()); + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoi) { + createProp(lowered, lowered.c_str()); + } + else { + // Type 0/1: scene actor, vehicle, or prop + LegoROI* roi = nullptr; + + // Check if this is a vehicle actor via ModelInfo flag + bool isVehicleActor = false; + for (uint8_t m = 0; m < p_animInfo->m_modelCount; m++) { + if (p_animInfo->m_models[m].m_name && + !SDL_strcasecmp(lowered.c_str(), p_animInfo->m_models[m].m_name) && + p_animInfo->m_models[m].m_unk0x2c) { + isVehicleActor = true; + break; + } + } + + // Try matching a participant's vehicle by category + if (isVehicleActor && !m_vehicleROI) { + MxU32 animVehicleIdx; + if (AnimationManager()->FindVehicle(lowered.c_str(), animVehicleIdx)) { + for (size_t p = 0; p < m_participants.size(); p++) { + if (!m_participants[p].vehicleROI) { + continue; + } + + MxU32 perfVehicleIdx; + if (AnimationManager()->FindVehicle(m_participants[p].vehicleROI->GetName(), perfVehicleIdx)) { + if (Catalog::GetVehicleCategory((int8_t) animVehicleIdx) == + Catalog::GetVehicleCategory((int8_t) perfVehicleIdx)) { + m_vehicleROI = m_participants[p].vehicleROI; + addAlias(lowered, m_vehicleROI); + roi = m_vehicleROI; + break; + } + } + } + } + } + + // Try creating from a registered LOD + if (!roi) { + roi = createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str()); + } + + // Fallback: clone an existing scene ROI (for models like BIRD + // whose LOD data is embedded in the world, not registered separately) + if (!roi) { + roi = cloneSceneROI(lowered); + } + + // Final fallback: borrow local player's vehicle via alias + if (!roi && m_participants[0].vehicleROI && !m_vehicleROI) { + m_vehicleROI = m_participants[0].vehicleROI; + addAlias(lowered, m_vehicleROI); + } + } + } + + m_propROIs = std::move(createdROIs); + + // Find root ROI: first non-spectator participant matched to an animation actor + LegoROI* rootROI = nullptr; + for (size_t p = 0; p < m_participants.size(); p++) { + if (!m_participants[p].IsSpectator() && participantMatched[p]) { + rootROI = m_participants[p].roi; + break; + } + } + + if (!rootROI && !m_participants.empty()) { + rootROI = m_participants[0].roi; + } + + if (!rootROI) { + return; + } + + m_animRootROI = rootROI; + + // Collect extra ROIs (other matched participants + props + vehicle) + std::vector 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 findOrigin = [&](LegoTreeNode* node, MxMatrix& parentWorld) -> bool { + LegoAnimNodeData* data = (LegoAnimNodeData*) node->GetData(); + MxU32 roiIdx = data ? data->GetROIIndex() : 0; + + MxMatrix localMat; + LegoROI::CreateLocalTransform(data, 0, localMat); + MxMatrix worldMat; + worldMat.Product(localMat, parentWorld); + + if (roiIdx != 0 && m_roiMap[roiIdx] == m_animRootROI) { + m_animPose0 = worldMat; + return true; + } + for (LegoU32 i = 0; i < node->GetNumChildren(); i++) { + if (findOrigin(node->GetChild(i), worldMat)) { + return true; + } + } + return false; + }; + MxMatrix identity; + identity.SetIdentity(); + findOrigin(m_currentData->anim->GetRoot(), identity); + + // Inverse of animPose0 (rigid body: transpose rotation, negate translated position) + MxMatrix invAnimPose0; + invAnimPose0.SetIdentity(); + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) { + invAnimPose0[r][c] = m_animPose0[c][r]; + } + } + for (int r = 0; r < 3; r++) { + invAnimPose0[3][r] = + -(invAnimPose0[0][r] * m_animPose0[3][0] + invAnimPose0[1][r] * m_animPose0[3][1] + + invAnimPose0[2][r] * m_animPose0[3][2]); + } + + m_rebaseMatrix.Product(invAnimPose0, targetTransform); + m_rebaseComputed = true; +} + +void ScenePlayer::ResolvePtAtCamROIs() +{ + m_ptAtCamROIs.clear(); + if (!m_currentData || m_currentData->ptAtCamNames.empty() || !m_roiMap) { + return; + } + + for (const auto& name : m_currentData->ptAtCamNames) { + for (MxU32 i = 1; i < m_roiMapSize; i++) { + if (m_roiMap[i] && m_roiMap[i]->GetName() && !SDL_strcasecmp(name.c_str(), m_roiMap[i]->GetName())) { + m_ptAtCamROIs.push_back(m_roiMap[i]); + break; + } + } + } +} + +void ScenePlayer::ApplyPtAtCam() +{ + if (m_ptAtCamROIs.empty()) { + return; + } + + // Find the spectator participant — PTATCAM tracks the spectator's position + // instead of the camera so the actor faces the same target for all players. + LegoROI* spectatorROI = nullptr; + for (const auto& p : m_participants) { + if (p.IsSpectator() && p.roi) { + spectatorROI = p.roi; + break; + } + } + + if (!spectatorROI) { + return; + } + + // Same math as LegoAnimPresenter::PutFrame + for (LegoROI* roi : m_ptAtCamROIs) { + if (!roi) { + continue; + } + + MxMatrix mat(roi->GetLocal2World()); + + Vector3 pos(mat[0]); + Vector3 dir(mat[1]); + Vector3 up(mat[2]); + Vector3 und(mat[3]); + + float possqr = sqrt(pos.LenSquared()); + float dirsqr = sqrt(dir.LenSquared()); + float upsqr = sqrt(up.LenSquared()); + + up = und; + up -= spectatorROI->GetWorldPosition(); + dir /= dirsqr; + pos.EqualsCross(dir, up); + pos.Unitize(); + up.EqualsCross(pos, dir); + pos *= possqr; + dir *= dirsqr; + up *= upsqr; + + roi->SetLocal2World(mat); + roi->WrappedUpdateWorldData(); + } +} + +void ScenePlayer::Tick() +{ + if (!m_playing || !m_currentData || m_participants.empty()) { + return; + } + + if (m_startTime == 0) { + m_startTime = SDL_GetTicks(); + } + + if (m_category == e_npcAnim && m_roiMap) { + AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize); + } + + float elapsed = (float) (SDL_GetTicks() - m_startTime); + + if (elapsed >= m_currentData->duration) { + Stop(); + return; + } + + // 1. Skeletal animation + if (m_currentData->anim && m_roiMap) { + if (!m_rebaseComputed) { + if (m_category == e_camAnim) { + // cam_anims use the action transform directly (keyframes are in world space) + if (m_currentData->actionTransform.valid) { + Mx3DPointFloat loc( + m_currentData->actionTransform.location[0], + m_currentData->actionTransform.location[1], + m_currentData->actionTransform.location[2] + ); + Mx3DPointFloat dir( + m_currentData->actionTransform.direction[0], + m_currentData->actionTransform.direction[1], + m_currentData->actionTransform.direction[2] + ); + Mx3DPointFloat up( + m_currentData->actionTransform.up[0], + m_currentData->actionTransform.up[1], + m_currentData->actionTransform.up[2] + ); + CalcLocalTransform(loc, dir, up, m_rebaseMatrix); + } + else { + m_rebaseMatrix.SetIdentity(); + } + m_rebaseComputed = true; + } + else { + ComputeRebaseMatrix(); + } + } + + AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap); + } + + // 2. Camera animation (cam_anim only) + if (m_hasCamAnim) { + MxMatrix camTransform(m_rebaseMatrix); + m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform); + + LegoWorld* world = CurrentWorld(); + if (world && world->GetCameraController()) { + world->GetCameraController()->TransformPointOfView(camTransform, FALSE); + } + } + + // 3. PTATCAM post-processing + ApplyPtAtCam(); + + // 4. Audio + const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr; + m_audioPlayer.Tick(elapsed, audioROIName); + + // 5. Phoneme frames + m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks); +} + +void ScenePlayer::Stop() +{ + if (!m_playing) { + return; + } + + m_audioPlayer.Cleanup(); + m_phonemePlayer.Cleanup(); + + if (m_hideOnStop && m_roiMap) { + for (MxU32 i = 1; i < m_roiMapSize; i++) { + if (m_roiMap[i]) { + m_roiMap[i]->SetVisibility(FALSE); + } + } + } + + if (m_hiddenVehicleROI) { + m_hiddenVehicleROI->SetVisibility(TRUE); + m_hiddenVehicleROI = nullptr; + } + + CleanupProps(); + m_vehicleROI = nullptr; + + delete[] m_roiMap; + m_roiMap = nullptr; + m_roiMapSize = 0; + + for (auto& p : m_participants) { + if (p.roi) { + p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform); + p.roi->SetVisibility(TRUE); + } + } + m_participants.clear(); + + BackgroundAudioManager()->RaiseVolume(); + + m_ptAtCamROIs.clear(); + m_actorAliases.clear(); + m_playing = false; + m_rebaseComputed = false; + m_currentData = nullptr; + m_animRootROI = nullptr; + m_hasCamAnim = false; + m_observerMode = false; + m_startTime = 0; + m_hideOnStop = false; +} + +void ScenePlayer::NotifyROIDestroyed(LegoROI* p_roi) +{ + if (!m_playing || !p_roi) { + return; + } + + // Walk the m_roiMap once to find p_roi and all its descendants (child ROIs + // are destroyed together with their parent). Collect them so every other + // field can be cleaned with simple pointer equality — the ancestor walk + // happens in exactly one place. + std::vector destroyed; + destroyed.push_back(p_roi); + + if (m_roiMap) { + for (MxU32 i = 0; i < m_roiMapSize; i++) { + if (!m_roiMap[i]) { + continue; + } + + for (OrientableROI* cur = m_roiMap[i]; cur != nullptr; cur = cur->GetParentROI()) { + if (cur == p_roi) { + if (m_roiMap[i] != p_roi) { + destroyed.push_back(m_roiMap[i]); + } + m_roiMap[i] = nullptr; + break; + } + } + } + } + + auto isDestroyed = [&destroyed](LegoROI* roi) { + for (LegoROI* d : destroyed) { + if (roi == d) { + return true; + } + } + return false; + }; + + for (auto& p : m_participants) { + if (p.roi && isDestroyed(p.roi)) { + p.roi = nullptr; + } + if (p.vehicleROI && isDestroyed(p.vehicleROI)) { + p.vehicleROI = nullptr; + } + } + + for (auto& roi : m_ptAtCamROIs) { + if (roi && isDestroyed(roi)) { + roi = nullptr; + } + } + + if (m_animRootROI && isDestroyed(m_animRootROI)) { + m_animRootROI = nullptr; + } + + if (m_vehicleROI && isDestroyed(m_vehicleROI)) { + m_vehicleROI = nullptr; + } + + for (LegoROI* d : destroyed) { + m_phonemePlayer.NotifyROIDestroyed(d); + } +} + +void ScenePlayer::CleanupProps() +{ + for (auto* propROI : m_propROIs) { + if (propROI) { + CharacterManager()->ReleaseAutoROI(propROI); + } + } + m_propROIs.clear(); + + for (auto* clonedROI : m_clonedSceneROIs) { + if (clonedROI) { + VideoManager()->Get3DManager()->Remove(*clonedROI); + delete clonedROI; + } + } + m_clonedSceneROIs.clear(); +} diff --git a/extensions/src/multiplayer/animation/sessionhost.cpp b/extensions/src/multiplayer/animation/sessionhost.cpp new file mode 100644 index 00000000..15b904c7 --- /dev/null +++ b/extensions/src/multiplayer/animation/sessionhost.cpp @@ -0,0 +1,310 @@ +#include "extensions/multiplayer/animation/sessionhost.h" + +#include "extensions/multiplayer/animation/catalog.h" +#include "extensions/multiplayer/animation/coordinator.h" + +#include + +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& p_changedAnims) +{ + RemovePlayerFromSessions(p_peerId, false, p_changedAnims); +} + +void SessionHost::RemovePlayerFromSessions( + uint32_t p_peerId, + bool p_includePlayingSessions, + std::vector& p_changedAnims +) +{ + std::vector 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& 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& 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& 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 SessionHost::Tick(uint32_t p_now) +{ + std::vector 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& 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(remaining); +} + +bool SessionHost::HasCountdownSession() const +{ + for (const auto& [animIndex, session] : m_sessions) { + if (session.state == CoordinationState::e_countdown) { + return true; + } + } + return false; +} + +std::vector SessionHost::ComputeSlotCharIndices(const CatalogEntry* p_entry) +{ + std::vector 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; +} diff --git a/extensions/src/multiplayer/emoteanimhandler.cpp b/extensions/src/multiplayer/emoteanimhandler.cpp new file mode 100644 index 00000000..2eba3aa1 --- /dev/null +++ b/extensions/src/multiplayer/emoteanimhandler.cpp @@ -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 +#include +#include +#include + +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& 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& 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 unmatchedNames; + CollectUnmatchedNodes(p_anim, p_playerROI, unmatchedNames); + if (unmatchedNames.empty()) { + return; + } + + std::vector 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 + ); +} diff --git a/extensions/src/multiplayer/mputils.cpp b/extensions/src/multiplayer/mputils.cpp new file mode 100644 index 00000000..9b87ad44 --- /dev/null +++ b/extensions/src/multiplayer/mputils.cpp @@ -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 + +using namespace Multiplayer; + +LegoROI* Multiplayer::DeepCloneROI(LegoROI* p_source, const char* p_name) +{ + Tgl::Renderer* renderer = VideoManager()->GetRenderer(); + ViewLODList* lodList = reinterpret_cast(const_cast(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 Multiplayer::ComputeChildOffsets(LegoROI* p_parent) +{ + std::vector 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& 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; +} diff --git a/extensions/src/multiplayer/namebubblerenderer.cpp b/extensions/src/multiplayer/namebubblerenderer.cpp new file mode 100644 index 00000000..2129b65f --- /dev/null +++ b/extensions/src/multiplayer/namebubblerenderer.cpp @@ -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 +#include + +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); +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp new file mode 100644 index 00000000..d0056be7 --- /dev/null +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -0,0 +1,2487 @@ +#include "extensions/multiplayer/networkmanager.h" + +#include "actions/isle_actions.h" +#include "extensions/common/arearestriction.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 "extensions/thirdpersoncamera.h" +#include "extensions/thirdpersoncamera/controller.h" +#include "isle.h" +#include "legoactor.h" +#include "legoanimationmanager.h" +#include "legocachsound.h" +#include "legocarbuild.h" +#include "legocharactermanager.h" +#include "legoextraactor.h" +#include "legogamestate.h" +#include "legomain.h" +#include "legopathactor.h" +#include "legopathcontroller.h" +#include "legoworld.h" +#include "misc.h" +#include "mxmisc.h" +#include "mxticklemanager.h" +#include "roi/legoroi.h" + +#include +#include +#include +#include +#include + +using namespace Extensions; +using namespace Multiplayer; +using Common::DetectVehicleType; +using Common::IsRestrictedArea; +using Multiplayer::IsMultiPartEmote; + +// Slightly larger than NPC_ANIM_PROXIMITY to catch transitions +static constexpr float NPC_ANIM_NEARBY_RADIUS_SQ = + (Animation::NPC_ANIM_PROXIMITY + 5.0f) * (Animation::NPC_ANIM_PROXIMITY + 5.0f); + +static const char* IDLE_ANIM_STATE_JSON = + "{\"locations\":[],\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}"; + +static void ExtractSlotPeerIds(const AnimUpdateMsg& p_msg, uint32_t p_out[8]) +{ + for (uint8_t i = 0; i < 8; i++) { + p_out[i] = (i < p_msg.slotCount) ? p_msg.slots[i].peerId : 0; + } +} + +template +void NetworkManager::SendMessage(const T& p_msg) +{ + SendFixedMessage(m_transport, p_msg); +} + +NetworkManager::NetworkManager() + : m_transport(nullptr), m_callbacks(nullptr), m_localNameBubble(nullptr), m_localPeerId(0), m_hostPeerId(0), + m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true), + m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), + m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), + m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), m_showNameBubbles(true), + m_lastCameraEnabled(false), m_lastVehicleState(0), m_wasInRestrictedArea(false), m_animStateDirty(false), + m_animInterestDirty(false), m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), + m_reconnectAttempt(0), m_reconnectDelay(0), m_nextReconnectTime(0), m_hornTemplates{}, m_activeHorns() +{ + m_animLoader.SetSIReader(&m_siReader); +} + +NetworkManager::~NetworkManager() +{ + Shutdown(); +} + +static ThirdPersonCamera::Controller* GetCamera() +{ + return ThirdPersonCameraExt::GetCamera(); +} + +MxResult NetworkManager::Tickle() +{ + ProcessPendingRequests(); + CheckConnectionState(); + + EnforceDisableNPCs(); + + // Detect camera state changes for platform notification + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + bool cameraEnabled = cam->IsEnabled(); + if (cameraEnabled != m_lastCameraEnabled) { + m_lastCameraEnabled = cameraEnabled; + m_animStateDirty = true; + NotifyThirdPersonChanged(cameraEnabled); + + // Cancel animation when camera is disabled (vehicle entry, restricted area, etc.) + if (!cameraEnabled && m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex(); + CancelLocalAnimInterest(); + if (localAnim != Animation::ANIM_INDEX_NONE) { + StopScenePlayback(localAnim, false); + } + } + + if (m_localNameBubble) { + if (!cameraEnabled) { + m_localNameBubble->SetVisible(false); + } + else if (m_showNameBubbles) { + m_localNameBubble->SetVisible(true); + } + } + } + + // Detect vehicle state changes for animation eligibility refresh. + // Tracks three states: on foot, on own vehicle, on foreign vehicle. + int8_t localChar = Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()); + uint8_t vehicleState = Animation::Catalog::GetVehicleState(localChar, cam->GetRideVehicleROI()); + if (vehicleState != m_lastVehicleState) { + m_lastVehicleState = vehicleState; + m_animStateDirty = true; + + // Cancel active session if the current animation is no longer eligible. + // Only cancel if the local player is a performer — spectators aren't vehicle-constrained. + if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + uint16_t currentAnim = m_animCoordinator.GetCurrentAnimIndex(); + if (currentAnim != Animation::ANIM_INDEX_NONE) { + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(currentAnim); + if (entry && (entry->performerMask >> localChar) & 1) { + if (!Animation::Catalog::CheckVehicleEligibility(entry, localChar, vehicleState)) { + CancelLocalAnimInterest(); + StopScenePlayback(currentAnim, false); + } + } + } + } + } + + // Create local name bubble when display ROI becomes available + if (m_showNameBubbles && !m_localNameBubble && cam->GetDisplayROI()) { + char name[USERNAME_BUFFER_SIZE]; + EncodeUsername(name); + m_localNameBubble = new NameBubbleRenderer(); + m_localNameBubble->Create(name); + } + + // Update local name bubble position + if (m_localNameBubble) { + LegoROI* bubbleROI = cam->GetDisplayROI(); + + // In large vehicles the display ROI is frozen; use the actual actor ROI instead + if (cam->IsInVehicle() && !cam->IsActive()) { + LegoPathActor* userActor = UserActor(); + if (userActor) { + bubbleROI = userActor->GetROI(); + } + } + + if (bubbleROI) { + m_localNameBubble->Update(bubbleROI); + } + } + } + + // Update local player location proximity + if (m_inIsleWorld) { + LegoPathActor* userActor = UserActor(); + if (userActor && userActor->GetROI()) { + const float* pos = userActor->GetROI()->GetWorldPosition(); + if (m_locationProximity.Update(pos[0], pos[2])) { + m_animStateDirty = true; + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + m_animCoordinator.OnLocationChanged(m_locationProximity.GetLocations(), &m_animCatalog); + + // Location change cleared interest — send cancel to host + if (oldState != Animation::CoordinationState::e_idle && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + if (IsHost()) { + HandleAnimCancel(m_localPeerId); + } + else if (IsConnected()) { + AnimCancelMsg cancelMsg{}; + cancelMsg.header = MakeHeader(MSG_ANIM_CANCEL, TARGET_HOST); + SendMessage(cancelMsg); + } + m_localPendingAnimInterest = -1; + } + } + } + + if (!IsHost() && m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) { + m_animStateDirty = true; + } + } + + if (IsHost()) { + TickHostSessions(); + } + + if (!m_transport) { + return SUCCESS; + } + + uint32_t now = SDL_GetTicks(); + + // Broadcast before receiving so the Send proxy lets the main thread + // process WebSocket events before we drain the queue. + if (m_transport->IsConnected() && (now - m_lastBroadcastTime) >= BROADCAST_INTERVAL_MS) { + BroadcastLocalState(); + m_lastBroadcastTime = now; + } + + ProcessIncomingPackets(); + UpdateRemotePlayers(Common::FIXED_TICK_DELTA); + TickAnimation(); + + // Re-read time; ProcessIncomingPackets may have advanced SDL_GetTicks. + uint32_t timeoutNow = SDL_GetTicks(); + std::vector timedOut; + for (auto& [peerId, player] : m_remotePlayers) { + uint32_t lastUpdate = player->GetLastUpdateTime(); + if (timeoutNow >= lastUpdate && (timeoutNow - lastUpdate) > TIMEOUT_MS) { + timedOut.push_back(peerId); + } + } + for (uint32_t peerId : timedOut) { + RemoveRemotePlayer(peerId); + } + + // Push animation state to frontend if dirty (throttled) + if (m_animStateDirty && m_inIsleWorld && m_callbacks) { + uint32_t pushNow = SDL_GetTicks(); + bool cooldownExpired = (pushNow - m_lastAnimPushTime) >= ANIM_PUSH_COOLDOWN_MS; + if (cooldownExpired || m_animInterestDirty) { + m_animStateDirty = false; + m_animInterestDirty = false; + m_lastAnimPushTime = pushNow; + PushAnimationState(); + } + } + + return SUCCESS; +} + +void NetworkManager::Initialize(NetworkTransport* p_transport, PlatformCallbacks* p_callbacks) +{ + m_transport = p_transport; + m_callbacks = p_callbacks; + m_worldSync.SetTransport(p_transport); +} + +void NetworkManager::HandleCreate() +{ + if (!m_registered) { + TickleManager()->RegisterClient(this, 10); + m_registered = true; + } + + // Load animation catalog early so the host can coordinate animations + // even before entering the Isle world (e.g. while in infocenter). + m_animCatalog.Refresh(); + m_animCoordinator.SetCatalog(&m_animCatalog); + m_animSessionHost.SetCatalog(&m_animCatalog); +} + +void NetworkManager::Shutdown() +{ + if (m_transport) { + Disconnect(); + if (m_registered) { + TickleManager()->UnregisterClient(this); + m_registered = false; + } + m_transport = nullptr; + m_worldSync.SetTransport(nullptr); + } + + CleanupHornSounds(); + + delete m_localNameBubble; + m_localNameBubble = nullptr; + + RemoveAllRemotePlayers(); +} + +void NetworkManager::Connect(const char* p_roomId) +{ + if (m_transport) { + m_roomId = p_roomId; + m_transport->Connect(p_roomId); + m_connectionState = STATE_CONNECTED; + } +} + +void NetworkManager::Disconnect() +{ + m_connectionState = STATE_DISCONNECTED; + if (m_transport) { + m_transport->Disconnect(); + } + RemoveAllRemotePlayers(); + ResetAnimationState(); +} + +bool NetworkManager::IsConnected() const +{ + return m_transport && m_transport->IsConnected(); +} + +bool NetworkManager::WasRejected() const +{ + return m_wasRejected; +} + +void NetworkManager::ResetAnimationState() +{ + // Notify clients that all active sessions are cancelled so they don't get stuck + // waiting for a countdown/start that will never come. + if (IsHost() && IsConnected()) { + std::vector activeAnims; + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + activeAnims.push_back(animIndex); + } + m_animSessionHost.Reset(); + for (uint16_t animIndex : activeAnims) { + SendMessage(BuildAnimUpdateMsg(animIndex, TARGET_BROADCAST)); // Session gone → state=0 + } + } + else { + m_animSessionHost.Reset(); + } + m_animCoordinator.Reset(); + m_localPendingAnimInterest = -1; + m_pendingAnimInterest.store(-1, std::memory_order_relaxed); + m_pendingAnimCancel.store(false, std::memory_order_relaxed); + m_animStateDirty = true; +} + +void NetworkManager::BroadcastChangedSessions(const std::vector& p_changedAnims) +{ + for (uint16_t idx : p_changedAnims) { + BroadcastAnimUpdate(idx); + } + m_animStateDirty = true; +} + +void NetworkManager::CancelLocalAnimInterest() +{ + m_animCoordinator.ClearInterest(); + m_localPendingAnimInterest = -1; + + if (IsHost()) { + HandleAnimCancel(m_localPeerId); + } + else if (IsConnected()) { + AnimCancelMsg msg{}; + msg.header = MakeHeader(MSG_ANIM_CANCEL, TARGET_HOST); + SendMessage(msg); + } + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::StopAnimation() +{ + StopAllPlayback(); + CancelLocalAnimInterest(); + m_animCoordinator.Reset(); + m_pendingAnimInterest.store(-1, std::memory_order_relaxed); + m_pendingAnimCancel.store(false, std::memory_order_relaxed); + m_animStateDirty = true; +} + +void NetworkManager::OnWorldEnabled(LegoWorld* p_world) +{ + if (!p_world) { + return; + } + + if (p_world->GetWorldId() == LegoOmni::e_act1) { + m_inIsleWorld = true; + m_wasInRestrictedArea = IsRestrictedArea(GameState()->m_currentArea); + m_worldSync.SetInIsleWorld(true); + + for (auto& [peerId, player] : m_remotePlayers) { + if (player->IsSpawned()) { + player->ReAddToScene(); + } + else { + player->Spawn(p_world); + if (player->GetROI()) { + m_roiToPlayer[player->GetROI()] = player.get(); + } + } + + if (player->IsSpawned() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) { + player->SetVisible(true); + player->SetNameBubbleVisible(m_showNameBubbles); + } + } + + NotifyPlayerCountChanged(); + EnforceDisableNPCs(); + + // Refresh animation catalog from DTA files for all supported worlds + m_animCatalog.Refresh(); + m_animCoordinator.SetCatalog(&m_animCatalog); + m_animSessionHost.SetCatalog(&m_animCatalog); + + m_locationProximity.Reset(); + PreloadHornSounds(); + } +} + +void NetworkManager::OnWorldDisabled(LegoWorld* p_world) +{ + if (!p_world) { + return; + } + + if (p_world->GetWorldId() == LegoOmni::e_act1) { + m_inIsleWorld = false; + m_wasInRestrictedArea = false; + m_worldSync.SetInIsleWorld(false); + + CleanupHornSounds(); + + // Stop animation before ROIs are destroyed (calls ResetAnimationState) + StopAnimation(); + m_animStateDirty = false; // override: we push explicit empty JSON below + m_locationProximity.Reset(); + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable( + "{\"location\":-1,\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}" + ); + } + + // Destroy local name bubble (ROI is about to be destroyed) + if (m_localNameBubble) { + m_localNameBubble->Destroy(); + delete m_localNameBubble; + m_localNameBubble = nullptr; + } + + for (auto& [peerId, player] : m_remotePlayers) { + player->SetVisible(false); + player->SetNameBubbleVisible(false); + } + + NotifyPlayerCountChanged(); + } +} + +void NetworkManager::OnBeforeSaveLoad() +{ + if (m_transport && m_transport->IsConnected() && !IsHost()) { + m_worldSync.SaveSkyLightState(); + } +} + +void NetworkManager::OnSaveLoaded() +{ + if (!m_transport || !m_transport->IsConnected()) { + return; + } + + // After a save file load, the local plant/building/sky/light state comes + // from the save file and may diverge from the host's state. + // Host broadcasts to all peers (targetPeerId=0); non-host restores the + // pre-load sky/light to avoid visual flicker, then requests a fresh snapshot. + if (IsHost()) { + m_worldSync.SendWorldSnapshotTo(0); + } + else { + m_worldSync.RestoreSkyLightState(); + m_worldSync.OnHostChanged(); + } +} + +MxBool NetworkManager::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType) +{ + return m_worldSync.HandleEntityMutation(p_entity, p_changeType); +} + +MxBool NetworkManager::HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType) +{ + return m_worldSync.HandleSkyLightMutation(p_entityType, p_changeType); +} + +void NetworkManager::EnforceDisableNPCs() +{ + LegoAnimationManager* am = AnimationManager(); + if (!am) { + return; + } + + am->m_numAllowedExtras = 0; + am->m_enableCamAnims = FALSE; + am->m_unk0x400 = FALSE; + + // Suppress build exit camera animations (triggered via FUN_10060dc0, + // which bypasses the m_enableCamAnims check) + static const char* buildStateNames[] = + {"LegoCopterBuildState", "LegoDuneCarBuildState", "LegoJetskiBuildState", "LegoRaceCarBuildState"}; + + for (const char* name : buildStateNames) { + LegoVehicleBuildState* state = (LegoVehicleBuildState*) GameState()->GetState(name); + if (state != nullptr) { + state->m_playedExitScript = TRUE; + } + } + + // Suppress first-time vehicle entry camera animations (triggered via + // ActivateSceneActions, which also bypasses the m_enableCamAnims check) + Act1State* act1state = (Act1State*) GameState()->GetState("Act1State"); + if (act1state != nullptr) { + act1state->m_playedExitExplanation = TRUE; + } + + // Purge all extras including ambient NPCs (mama, papa, brickster) + // that are spawned by camera path triggers via FUN_10064380. + // PurgeExtra(TRUE) deliberately skips mama/papa, so we purge manually. + for (MxS32 i = 0; i < (MxS32) sizeOfArray(am->m_extras); i++) { + if (am->m_extras[i].m_roi != nullptr) { + LegoPathActor* actor = CharacterManager()->GetExtraActor(am->m_extras[i].m_roi->GetName()); + if (actor != nullptr && actor->GetController() != nullptr) { + actor->GetController()->RemoveActor(actor); + actor->SetController(nullptr); + } + + CharacterManager()->ReleaseActor(am->m_extras[i].m_roi); + am->m_extras[i].m_roi = nullptr; + am->m_extras[i].m_characterId = -1; + am->m_unk0x414--; + } + } +} + +void NetworkManager::CheckConnectionState() +{ + if (!m_transport || m_connectionState == STATE_DISCONNECTED) { + return; + } + + if (m_connectionState == STATE_CONNECTED) { + if (!m_transport->WasDisconnected()) { + return; + } + + if (m_transport->WasRejected()) { + // Room full on initial connect - flag for game loop to exit + m_wasRejected = true; + m_connectionState = STATE_DISCONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_REJECTED); + } + return; + } + + // Connection lost - enter reconnection + m_connectionState = STATE_RECONNECTING; + RemoveAllRemotePlayers(); + m_reconnectAttempt = 0; + m_reconnectDelay = RECONNECT_INITIAL_DELAY_MS; + m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_RECONNECTING); + } + return; + } + + // STATE_RECONNECTING + if (m_transport->IsConnected()) { + ResetStateAfterReconnect(); + m_connectionState = STATE_CONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_CONNECTED); + } + return; + } + + uint32_t now = SDL_GetTicks(); + if (now < m_nextReconnectTime) { + return; + } + + if (m_reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) { + // Give up - stay alive but without multiplayer + m_connectionState = STATE_DISCONNECTED; + + if (m_callbacks) { + m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_FAILED); + } + return; + } + + AttemptReconnect(); +} + +void NetworkManager::AttemptReconnect() +{ + m_reconnectAttempt++; + m_transport->Disconnect(); + m_transport->Connect(m_roomId.c_str()); + m_reconnectDelay = SDL_min(m_reconnectDelay * 2, RECONNECT_MAX_DELAY_MS); + m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay; +} + +void NetworkManager::ResetStateAfterReconnect() +{ + m_localPeerId = 0; + m_hostPeerId = 0; + m_sequence = 0; + m_lastBroadcastTime = 0; + m_worldSync.ResetForReconnect(); + ResetAnimationState(); +} + +MessageHeader NetworkManager::MakeHeader(uint8_t p_type, uint32_t p_target) +{ + return {p_type, 0, m_localPeerId, m_sequence++, p_target}; +} + +void NetworkManager::ProcessPendingRequests() +{ + ThirdPersonCamera::Controller* cam = GetCamera(); + + // Camera-dependent requests: only consume when cam is available so + // the request survives until the camera exists. + if (cam) { + if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) { + if (cam->IsEnabled()) { + if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex(); + CancelLocalAnimInterest(); + if (localAnim != Animation::ANIM_INDEX_NONE) { + StopScenePlayback(localAnim, false); + } + } + cam->Disable(); + NotifyThirdPersonChanged(false); + } + else { + cam->Enable(); + NotifyThirdPersonChanged(true); + } + } + + int walkAnim = m_pendingWalkAnim.exchange(-1, std::memory_order_relaxed); + if (walkAnim >= 0) { + SetWalkAnimation(static_cast(walkAnim)); + } + + int idleAnim = m_pendingIdleAnim.exchange(-1, std::memory_order_relaxed); + if (idleAnim >= 0) { + SetIdleAnimation(static_cast(idleAnim)); + } + + int emote = m_pendingEmote.exchange(-1, std::memory_order_relaxed); + if (emote >= 0) { + SendEmote(static_cast(emote)); + } + } + + int32_t animInterest = m_pendingAnimInterest.exchange(-1, std::memory_order_relaxed); + if (animInterest >= 0) { + // Discard during countdown or playback — player is committed + Animation::CoordinationState coordState = m_animCoordinator.GetState(); + bool canChangeInterest = + (coordState == Animation::CoordinationState::e_idle || + coordState == Animation::CoordinationState::e_interested); + + if (canChangeInterest) { + uint16_t animIndex = static_cast(animInterest); + m_animCoordinator.SetInterest(animIndex); + m_localPendingAnimInterest = animInterest; + + if (IsHost()) { + uint8_t displayActorIndex = 0; + ThirdPersonCamera::Controller* animCam = GetCamera(); + if (animCam) { + displayActorIndex = animCam->GetDisplayActorIndex(); + } + HandleAnimInterest(m_localPeerId, animIndex, displayActorIndex); + + // If slot assignment failed, clear optimistic interest + if (!m_animCoordinator.IsLocalPlayerInSession(animIndex)) { + m_animCoordinator.ClearInterest(); + m_localPendingAnimInterest = -1; + } + } + else if (IsConnected()) { + AnimInterestMsg msg{}; + msg.header = MakeHeader(MSG_ANIM_INTEREST, TARGET_HOST); + msg.animIndex = animIndex; + ThirdPersonCamera::Controller* animCam = GetCamera(); + msg.displayActorIndex = animCam ? animCam->GetDisplayActorIndex() : 0; + SendMessage(msg); + } + + m_animStateDirty = true; + m_animInterestDirty = true; + } + } + + if (m_pendingAnimCancel.exchange(false, std::memory_order_relaxed)) { + CancelLocalAnimInterest(); + } + + if (m_pendingToggleAllowCustomize.exchange(false, std::memory_order_relaxed)) { + m_localAllowRemoteCustomize = !m_localAllowRemoteCustomize; + NotifyAllowCustomizeChanged(m_localAllowRemoteCustomize); + } + + if (m_pendingToggleNameBubbles.exchange(false, std::memory_order_relaxed)) { + m_showNameBubbles = !m_showNameBubbles; + for (auto& [peerId, player] : m_remotePlayers) { + player->SetNameBubbleVisible(m_showNameBubbles); + } + if (m_localNameBubble) { + m_localNameBubble->SetVisible(m_showNameBubbles); + } + NotifyNameBubblesChanged(m_showNameBubbles); + } +} + +void NetworkManager::BroadcastLocalState() +{ + if (!m_transport) { + return; + } + + LegoPathActor* userActor = UserActor(); + LegoWorld* currentWorld = CurrentWorld(); + + if (!userActor || !currentWorld) { + return; + } + + LegoROI* roi = userActor->GetROI(); + if (!roi) { + return; + } + + const float* pos = roi->GetWorldPosition(); + const float* dir = roi->GetWorldDirection(); + const float* up = roi->GetWorldUp(); + float speed = userActor->GetWorldSpeed(); + + uint8_t actorId = static_cast(userActor)->GetActorId(); + if (IsValidActorId(actorId)) { + m_lastValidActorId = actorId; + } + else { + actorId = m_lastValidActorId; + } + + if (!IsValidActorId(actorId)) { + return; + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + + bool inRestrictedArea = IsRestrictedArea(GameState()->m_currentArea); + if (m_inIsleWorld && m_wasInRestrictedArea != inRestrictedArea) { + m_wasInRestrictedArea = inRestrictedArea; + NotifyPlayerCountChanged(); + } + + PlayerStateMsg msg{}; + msg.header = MakeHeader(MSG_STATE, TARGET_BROADCAST); + msg.actorId = actorId; + msg.worldId = inRestrictedArea ? WORLD_NOT_VISIBLE : (int8_t) currentWorld->GetWorldId(); + msg.vehicleType = DetectVehicleType(userActor); + SDL_memcpy(msg.position, pos, sizeof(msg.position)); + SDL_memcpy(msg.direction, dir, sizeof(msg.direction)); + SDL_memcpy(msg.up, up, sizeof(msg.up)); + msg.speed = speed; + + EncodeUsername(msg.name); + + if (cam) { + msg.walkAnimId = cam->GetWalkAnimId(); + msg.idleAnimId = cam->GetIdleAnimId(); + msg.displayActorIndex = cam->GetDisplayActorIndex(); + Multiplayer::PackCustomizeState(cam->GetCustomizeState(), msg.customizeData); + + // Encode multi-part emote frozen state + int8_t frozenId = cam->GetFrozenExtraAnimId(); + if (frozenId >= 0) { + msg.customizeFlags |= CUSTOMIZE_FLAG_FROZEN; + msg.customizeFlags |= (frozenId & CUSTOMIZE_FLAG_FROZEN_EMOTE_MASK) << CUSTOMIZE_FLAG_FROZEN_EMOTE_SHIFT; + } + + // Zero speed when in any phase of a multi-part emote or animation playback + if (cam->IsExtraAnimBlocking() || cam->IsAnimPlaying()) { + msg.speed = 0.0f; + } + } + + msg.customizeFlags |= m_localAllowRemoteCustomize ? CUSTOMIZE_FLAG_ALLOW_REMOTE : 0x00; + + SendMessage(msg); +} + +void NetworkManager::ProcessIncomingPackets() +{ + if (!m_transport) { + return; + } + + m_transport->Receive([this](const uint8_t* data, size_t length) { + uint8_t msgType = ParseMessageType(data, length); + + switch (msgType) { + case MSG_ASSIGN_ID: { + if (length >= 5) { + uint32_t assignedId; + SDL_memcpy(&assignedId, data + 1, sizeof(uint32_t)); + m_localPeerId = assignedId; + m_worldSync.SetLocalPeerId(assignedId); + m_animCoordinator.SetLocalPeerId(assignedId); + + LegoAnimationManager::configureLegoAnimationManager(0); + if (AnimationManager()) { + AnimationManager()->m_maxAllowedExtras = 0; + AnimationManager()->m_numAllowedExtras = 0; + } + EnforceDisableNPCs(); + } + break; + } + case MSG_HOST_ASSIGN: { + HostAssignMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleHostAssign(msg); + } + break; + } + case MSG_LEAVE: { + PlayerLeaveMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleLeave(msg); + } + break; + } + case MSG_STATE: { + PlayerStateMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleState(msg); + } + break; + } + case MSG_REQUEST_SNAPSHOT: { + RequestSnapshotMsg msg; + if (DeserializeMsg(data, length, msg)) { + m_worldSync.HandleRequestSnapshot(msg); + } + break; + } + case MSG_WORLD_SNAPSHOT: { + m_worldSync.HandleWorldSnapshot(data, length); + break; + } + case MSG_WORLD_EVENT: { + WorldEventMsg msg; + if (DeserializeMsg(data, length, msg)) { + m_worldSync.HandleWorldEvent(msg); + } + break; + } + case MSG_WORLD_EVENT_REQUEST: { + WorldEventRequestMsg msg; + if (DeserializeMsg(data, length, msg)) { + m_worldSync.HandleWorldEventRequest(msg); + } + break; + } + case MSG_EMOTE: { + EmoteMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleEmote(msg); + } + break; + } + case MSG_HORN: { + HornMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleHorn(msg); + } + break; + } + case MSG_CUSTOMIZE: { + CustomizeMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleCustomize(msg); + } + break; + } + case MSG_ANIM_INTEREST: { + AnimInterestMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleAnimInterest(msg.header.peerId, msg.animIndex, msg.displayActorIndex); + } + break; + } + case MSG_ANIM_CANCEL: { + AnimCancelMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleAnimCancel(msg.header.peerId); + } + break; + } + case MSG_ANIM_UPDATE: { + AnimUpdateMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleAnimUpdate(msg); + } + break; + } + case MSG_ANIM_START: { + AnimStartMsg msg; + if (DeserializeMsg(data, length, msg)) { + HandleAnimStart(msg); + } + break; + } + default: + break; + } + }); +} + +void NetworkManager::UpdateRemotePlayers(float p_deltaTime) +{ + float radius = m_locationProximity.GetRadius(); + const auto& localLocs = m_locationProximity.GetLocations(); + bool anyInIsle = false; + + for (auto& [peerId, player] : m_remotePlayers) { + player->Tick(p_deltaTime); + + // Derive locations from remote player's network-reported position. + // Skip players not in the isle world — their position is stale. + if (player->GetWorldId() == (int8_t) LegoOmni::e_act1 && player->HasReceivedUpdate()) { + anyInIsle = true; + + float px, pz; + player->GetTargetPosition(px, pz); + + auto oldLocs = player->GetLocations(); + auto newLocs = Animation::LocationProximity::ComputeAll(px, pz, radius); + player->SetLocations(std::move(newLocs)); + if (oldLocs != player->GetLocations()) { + // Dirty if remote's locations changed and any overlap with local player's locations + for (int16_t loc : localLocs) { + if (player->IsAtLocation(loc) || std::find(oldLocs.begin(), oldLocs.end(), loc) != oldLocs.end()) { + m_animStateDirty = true; + break; + } + } + } + } + } + + // Keep pushing while remote players are in the isle world so proximity-based + // eligibility and session display stay up to date as players move around + if (anyInIsle) { + m_animStateDirty = true; + } +} + +RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex) +{ + auto player = std::make_unique(p_peerId, p_actorId, p_displayActorIndex); + + if (m_inIsleWorld) { + LegoWorld* world = CurrentWorld(); + if (world && world->GetWorldId() == LegoOmni::e_act1) { + player->Spawn(world); + } + } + + RemotePlayer* ptr = player.get(); + m_remotePlayers[p_peerId] = std::move(player); + + if (ptr->GetROI()) { + m_roiToPlayer[ptr->GetROI()] = ptr; + } + + return ptr; +} + +void NetworkManager::HandleLeave(const PlayerLeaveMsg& p_msg) +{ + RemoveRemotePlayer(p_msg.header.peerId); +} + +void NetworkManager::HandleState(const PlayerStateMsg& p_msg) +{ + uint32_t peerId = p_msg.header.peerId; + + auto it = m_remotePlayers.find(peerId); + if (it == m_remotePlayers.end()) { + if (!IsValidActorId(p_msg.actorId)) { + return; + } + + CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex); + NotifyPlayerCountChanged(); + it = m_remotePlayers.find(peerId); + + // Send existing session state so the new player sees active sessions + if (IsHost()) { + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + SendAnimUpdateToPlayer(animIndex, peerId); + } + } + } + + // Respawn only if display actor changed (not on actorId change) + if (it->second->GetDisplayActorIndex() != p_msg.displayActorIndex) { + NotifyAnimationsROIDestroyed(it->second.get()); + if (it->second->GetROI()) { + m_roiToPlayer.erase(it->second->GetROI()); + } + it->second->Despawn(); + m_remotePlayers.erase(it); + CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex); + it = m_remotePlayers.find(peerId); + m_animStateDirty = true; + + if (IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } + } + else if (IsValidActorId(p_msg.actorId)) { + it->second->SetActorId(p_msg.actorId); // Update for future use, no visual change + } + + int8_t oldWorldId = it->second->GetWorldId(); + + it->second->UpdateFromNetwork(p_msg); + + bool bothInIsle = m_inIsleWorld && (p_msg.worldId == (int8_t) LegoOmni::e_act1); + if (it->second->IsSpawned()) { + it->second->SetVisible(bothInIsle); + it->second->SetNameBubbleVisible(bothInIsle && m_showNameBubbles); + } + + bool wasInIsle = (oldWorldId == (int8_t) LegoOmni::e_act1); + bool nowInIsle = (p_msg.worldId == (int8_t) LegoOmni::e_act1); + if (m_inIsleWorld && wasInIsle != nowInIsle) { + NotifyPlayerCountChanged(); + m_animStateDirty = true; + + // Player left the isle world — remove from animation sessions + if (wasInIsle && !nowInIsle && IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } + } +} + +void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg) +{ + uint32_t oldHost = m_hostPeerId; + m_hostPeerId = p_msg.hostPeerId; + + m_worldSync.SetHost(IsHost()); + + if (oldHost != m_hostPeerId) { + if (!IsHost()) { + m_worldSync.OnHostChanged(); + } + // Reset coordination on actual host change, not initial assignment. + // Initial assignment (oldHost==0) may race with session updates from the host. + if (oldHost != 0) { + ResetAnimationState(); + } + } +} + +void NetworkManager::SetWalkAnimation(uint8_t p_walkAnimId) +{ + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam && p_walkAnimId < Common::g_walkAnimCount) { + cam->SetWalkAnimId(p_walkAnimId); + } +} + +void NetworkManager::SetIdleAnimation(uint8_t p_idleAnimId) +{ + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam && p_idleAnimId < Common::g_idleAnimCount) { + cam->SetIdleAnimId(p_idleAnimId); + } +} + +void NetworkManager::SendEmote(uint8_t p_emoteId) +{ + if (p_emoteId >= Multiplayer::g_emoteAnimCount) { + return; + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (!cam) { + return; + } + + // Multi-part emotes require 3rd person camera to be active (they need the display clone). + // In 1st person mode, skip them entirely to avoid broadcasting an emote the local player can't play. + if (!cam->IsActive() && IsMultiPartEmote(p_emoteId)) { + return; + } + + cam->TriggerExtraAnim(p_emoteId); + + EmoteMsg msg{}; + msg.header = MakeHeader(MSG_EMOTE, TARGET_BROADCAST); + msg.emoteId = p_emoteId; + SendMessage(msg); +} + +void NetworkManager::HandleEmote(const EmoteMsg& p_msg) +{ + uint32_t peerId = p_msg.header.peerId; + auto it = m_remotePlayers.find(peerId); + if (it != m_remotePlayers.end()) { + it->second->TriggerExtraAnim(p_msg.emoteId); + } +} + +void NetworkManager::SendHorn(int8_t p_vehicleType) +{ + if (!IsConnected() || !m_inIsleWorld) { + return; + } + + HornMsg msg{}; + msg.header = MakeHeader(MSG_HORN, TARGET_BROADCAST); + msg.vehicleType = static_cast(p_vehicleType); + SendMessage(msg); +} + +// Vehicle type and dashboard composite ID for each horn-capable vehicle +struct HornVehicleInfo { + int8_t vehicleType; + uint32_t dashboardObjectId; +}; + +static const HornVehicleInfo g_hornVehicles[] = { + {VEHICLE_BIKE, IsleScript::c_BikeDashboard}, + {VEHICLE_AMBULANCE, IsleScript::c_AmbulanceDashboard}, + {VEHICLE_TOWTRACK, IsleScript::c_TowTrackDashboard}, + {VEHICLE_DUNEBUGGY, IsleScript::c_DuneCarDashboard}, +}; + +void NetworkManager::HandleHorn(const HornMsg& p_msg) +{ + // Sweep finished horn sounds + for (auto it = m_activeHorns.begin(); it != m_activeHorns.end();) { + if (!ma_sound_is_playing((*it)->m_cacheSound)) { + (*it)->Stop(); + delete *it; + it = m_activeHorns.erase(it); + } + else { + ++it; + } + } + + uint32_t peerId = p_msg.header.peerId; + auto it = m_remotePlayers.find(peerId); + if (it == m_remotePlayers.end()) { + return; + } + + // Find horn template for this vehicle type + int templateIdx = -1; + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + if (g_hornVehicles[i].vehicleType == static_cast(p_msg.vehicleType)) { + templateIdx = i; + break; + } + } + + if (templateIdx < 0 || !m_hornTemplates[templateIdx]) { + return; + } + + LegoCacheSound* horn = m_hornTemplates[templateIdx]->Clone(); + if (horn) { + ma_sound_set_doppler_factor(horn->m_cacheSound, 0); + horn->Play(it->second->GetUniqueName(), FALSE); + m_activeHorns.push_back(horn); + } +} + +void NetworkManager::PreloadHornSounds() +{ + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + m_hornTemplates[i] = nullptr; + + AudioTrack* track = m_siReader.ExtractFirstAudio(g_hornVehicles[i].dashboardObjectId); + if (!track) { + continue; + } + + LegoCacheSound* sound = new LegoCacheSound(); + MxString mediaSrcPath(track->mediaSrcPath.c_str()); + MxWavePresenter::WaveFormat format = track->format; + if (sound->Create(format, mediaSrcPath, track->volume, track->pcmData, track->pcmDataSize) == SUCCESS) { + ma_sound_set_doppler_factor(sound->m_cacheSound, 0); + m_hornTemplates[i] = sound; + } + else { + delete sound; + } + + delete[] track->pcmData; + delete track; + } +} + +void NetworkManager::CleanupHornSounds() +{ + for (auto* horn : m_activeHorns) { + horn->Stop(); + delete horn; + } + m_activeHorns.clear(); + + for (int i = 0; i < HORN_VEHICLE_COUNT; i++) { + delete m_hornTemplates[i]; + m_hornTemplates[i] = nullptr; + } +} + +void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) +{ + auto it = m_remotePlayers.find(p_peerId); + if (it != m_remotePlayers.end()) { + const auto& localLocs = m_locationProximity.GetLocations(); + for (int16_t loc : it->second->GetLocations()) { + if (std::find(localLocs.begin(), localLocs.end(), loc) != localLocs.end()) { + m_animStateDirty = true; + break; + } + } + NotifyAnimationsROIDestroyed(it->second.get()); + if (it->second->GetROI()) { + m_roiToPlayer.erase(it->second->GetROI()); + } + it->second->Despawn(); + m_remotePlayers.erase(it); + NotifyPlayerCountChanged(); + + if (IsHost()) { + std::vector changedAnims; + if (m_animSessionHost.HandlePlayerRemoved(p_peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + } + } + } +} + +void NetworkManager::RemoveAllRemotePlayers() +{ + for (auto& [peerId, player] : m_remotePlayers) { + NotifyAnimationsROIDestroyed(player.get()); + player->Despawn(); + } + m_remotePlayers.clear(); + m_roiToPlayer.clear(); + m_animStateDirty = true; + NotifyPlayerCountChanged(); +} + +void NetworkManager::NotifyPlayerCountChanged() +{ + if (!m_callbacks) { + return; + } + + int count = -1; + if (m_inIsleWorld) { + count = 0; + + // Only count the local player if they have a valid actor and + // are not in a restricted overlay area (elevator, observatory, etc.). + if (!IsRestrictedArea(GameState()->m_currentArea)) { + LegoPathActor* userActor = UserActor(); + if (userActor && IsValidActorId(static_cast(userActor)->GetActorId())) { + count = 1; + } + else if (IsValidActorId(GameState()->GetActorId())) { + count = 1; + } + } + + for (auto& [peerId, player] : m_remotePlayers) { + if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) { + count++; + } + } + } + + m_callbacks->OnPlayerCountChanged(count); +} + +void NetworkManager::NotifyThirdPersonChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnThirdPersonChanged(p_enabled); +} + +void NetworkManager::NotifyNameBubblesChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnNameBubblesChanged(p_enabled); +} + +void NetworkManager::NotifyAllowCustomizeChanged(bool p_enabled) +{ + if (!m_callbacks) { + return; + } + + m_callbacks->OnAllowCustomizeChanged(p_enabled); +} + +RemotePlayer* NetworkManager::FindPlayerByROI(LegoROI* p_roi) const +{ + auto it = m_roiToPlayer.find(p_roi); + if (it != m_roiToPlayer.end()) { + return it->second; + } + return nullptr; +} + +bool NetworkManager::IsClonedCharacter(const char* p_name) const +{ + // Check remote player clones + for (auto& it : m_remotePlayers) { + if (!SDL_strcasecmp(it.second->GetUniqueName(), p_name)) { + return true; + } + } + + return false; +} + +void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex) +{ + CustomizeMsg msg{}; + msg.header = MakeHeader(MSG_CUSTOMIZE, TARGET_BROADCAST_ALL); + msg.targetPeerId = p_targetPeerId; + msg.changeType = p_changeType; + msg.partIndex = p_partIndex; + SendMessage(msg); +} + +void NetworkManager::StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes) +{ + auto it = m_playingAnims.find(p_animIndex); + if (it == m_playingAnims.end()) { + return; + } + + // Save before Stop() which resets the flag + bool wasObserver = it->second->IsObserverMode(); + + if (it->second->IsPlaying()) { + it->second->Stop(); + } + + if (p_unlockRemotes) { + UnlockRemotesForAnim(p_animIndex); + } + + // Release camera if local player was a participant (not observer) in this animation + if (!wasObserver) { + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + } + + m_pendingCompletionJson.erase(p_animIndex); + m_playingAnims.erase(it); +} + +void NetworkManager::StopAllPlayback() +{ + for (auto& [animIndex, scenePlayer] : m_playingAnims) { + if (scenePlayer->IsPlaying()) { + scenePlayer->Stop(); + } + } + m_playingAnims.clear(); + m_pendingCompletionJson.clear(); + + for (auto& [peerId, player] : m_remotePlayers) { + player->ForceUnlockAnimation(); + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } +} + +void NetworkManager::NotifyAnimationsROIDestroyed(RemotePlayer* p_player) +{ + LegoROI* roi = p_player->GetROI(); + LegoROI* vehicleROI = p_player->GetRideVehicleROI(); + + for (auto& [animIndex, scenePlayer] : m_playingAnims) { + if (roi) { + scenePlayer->NotifyROIDestroyed(roi); + } + if (vehicleROI) { + scenePlayer->NotifyROIDestroyed(vehicleROI); + } + } +} + +void NetworkManager::UnlockRemotesForAnim(uint16_t p_animIndex) +{ + for (auto& [peerId, player] : m_remotePlayers) { + player->UnlockFromAnimation(p_animIndex); + } +} + +void NetworkManager::TickAnimation() +{ + if (m_playingAnims.empty()) { + return; + } + + // Collect completed animations with their observer mode (Tick/Stop resets the flag) + std::vector> completed; + + for (auto& [animIndex, scenePlayer] : m_playingAnims) { + if (!scenePlayer->IsPlaying()) { + completed.push_back({animIndex, scenePlayer->IsObserverMode()}); + continue; + } + + bool wasObserver = scenePlayer->IsObserverMode(); + scenePlayer->Tick(); + + if (!scenePlayer->IsPlaying()) { + completed.push_back({animIndex, wasObserver}); + } + } + + for (auto& [animIndex, wasObserver] : completed) { + UnlockRemotesForAnim(animIndex); + + if (!wasObserver) { + // Fire cached completion callback before cleanup destroys state + auto compIt = m_pendingCompletionJson.find(animIndex); + if (compIt != m_pendingCompletionJson.end()) { + if (m_callbacks && !compIt->second.empty()) { + m_callbacks->OnAnimationCompleted(compIt->second.c_str()); + } + m_pendingCompletionJson.erase(compIt); + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + m_animCoordinator.ResetLocalState(); + } + + m_playingAnims.erase(animIndex); + m_animStateDirty = true; + m_animInterestDirty = true; + } +} + +void NetworkManager::TickHostSessions() +{ + // Check co-location for all sessions: start/revert countdown as needed. + // For cam anims, also auto-remove players who left the required location. + // Use a snapshot of keys since we may modify sessions during iteration. + std::vector sessionKeys; + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + sessionKeys.push_back(animIndex); + } + + for (uint16_t animIndex : sessionKeys) { + const Animation::AnimSession* session = m_animSessionHost.FindSession(animIndex); + if (!session || session->state == Animation::CoordinationState::e_playing) { + continue; + } + + // For cam anims: auto-remove players who left the required location + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(animIndex); + if (entry && entry->location >= 0) { + std::vector toRemove; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && !IsPeerAtLocation(slot.peerId, entry->location)) { + toRemove.push_back(slot.peerId); + } + } + for (uint32_t pid : toRemove) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + session = m_animSessionHost.FindSession(animIndex); + if (!session) { + continue; + } + } + + // Auto-remove participants whose vehicle state no longer matches + if (entry && entry->vehicleMask) { + std::vector toRemove; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && !slot.IsSpectator()) { + int8_t charIdx = slot.charIndex; + uint8_t onVehicle = GetPeerVehicleState(slot.peerId, charIdx); + if (!Animation::Catalog::CheckVehicleEligibility(entry, charIdx, onVehicle)) { + toRemove.push_back(slot.peerId); + } + } + } + for (uint32_t pid : toRemove) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + session = m_animSessionHost.FindSession(animIndex); + if (!session) { + continue; + } + } + + bool allFilled = m_animSessionHost.AreAllSlotsFilled(animIndex); + bool coLocated = allFilled && ValidateSessionLocations(animIndex); + + if (session->state == Animation::CoordinationState::e_interested && coLocated) { + m_animSessionHost.StartCountdown(animIndex); + + if (m_animCoordinator.IsLocalPlayerInSession(animIndex)) { + const Animation::CatalogEntry* ce = m_animCatalog.FindEntry(animIndex); + const AnimInfo* ai = ce ? m_animCatalog.GetAnimInfo(animIndex) : nullptr; + if (ai) { + m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId); + } + } + + BroadcastAnimUpdate(animIndex); + m_animStateDirty = true; + } + else if (session->state == Animation::CoordinationState::e_countdown && !coLocated) { + m_animSessionHost.RevertCountdown(animIndex); + BroadcastAnimUpdate(animIndex); + m_animStateDirty = true; + } + } + + // Check countdown expiry — multiple animations may be ready simultaneously + std::vector readyAnims = m_animSessionHost.Tick(SDL_GetTicks()); + for (uint16_t readyAnim : readyAnims) { + uint64_t eventId = (static_cast(SDL_rand_bits()) << 32) | static_cast(SDL_rand_bits()); + BroadcastAnimStart(readyAnim, eventId); + + // Erase session immediately — the host's job ends at launch. + // Clients play independently and fire completion callbacks locally. + m_animSessionHost.EraseSession(readyAnim); + + if (m_inIsleWorld) { + HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim), eventId); + } + } + + // During countdown, push state every tick so countdownMs reaches the frontend + if (m_animSessionHost.HasCountdownSession()) { + m_animStateDirty = true; + } +} + +void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex) +{ + if (!IsHost()) { + return; + } + + // For location-bound animations, player must be at that location + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (entry && entry->location >= 0) { + if (!IsPeerAtLocation(p_peerId, entry->location)) { + return; + } + } + + // Validate vehicle eligibility if the joining player would be a performer + if (entry) { + int8_t charIndex = Animation::Catalog::DisplayActorToCharacterIndex(p_displayActorIndex); + if ((entry->performerMask >> charIndex) & 1) { + uint8_t onVehicle = GetPeerVehicleState(p_peerId, charIndex); + if (!Animation::Catalog::CheckVehicleEligibility(entry, charIndex, onVehicle)) { + return; + } + } + } + + // For NPC anims: if all slots are full, remove far-away participants to make room + // for the new nearby player. This only fires when slots are exhausted — if there's + // an open slot, the new player just joins normally without disturbing anyone. + if (entry && entry->location == -1 && m_animSessionHost.AreAllSlotsFilled(p_animIndex)) { + float newX, newZ; + if (GetPeerPosition(p_peerId, newX, newZ)) { + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (session) { + std::vector stale; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && slot.peerId != p_peerId && !IsPeerNearby(slot.peerId, newX, newZ)) { + stale.push_back(slot.peerId); + } + } + for (uint32_t pid : stale) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + } + } + } + + std::vector changedAnims; + if (m_animSessionHost.HandleInterest(p_peerId, p_animIndex, p_displayActorIndex, changedAnims)) { + BroadcastChangedSessions(changedAnims); + m_animInterestDirty = true; + } +} + +void NetworkManager::HandleAnimCancel(uint32_t p_peerId) +{ + if (!IsHost()) { + return; + } + + uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex(); + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + + std::vector changedAnims; + if (m_animSessionHost.HandleCancel(p_peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + m_animInterestDirty = true; + } + + // Stop local player's animation if their session was erased + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle && + localAnimBefore != Animation::ANIM_INDEX_NONE) { + StopScenePlayback(localAnimBefore, true); + } + + // Stop observer-mode playback for any erased playing sessions + for (uint16_t animIndex : changedAnims) { + if (animIndex != localAnimBefore && m_playingAnims.count(animIndex)) { + StopScenePlayback(animIndex, true); + } + } +} + +void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg) +{ + if (IsHost()) { + return; // Host already updated its own state + } + + uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex(); + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + + uint32_t slots[8]; + ExtractSlotPeerIds(p_msg, slots); + + m_animCoordinator.ApplySessionUpdate(p_msg.animIndex, p_msg.state, p_msg.countdownMs, slots, p_msg.slotCount); + + if (p_msg.state == static_cast(Animation::CoordinationState::e_countdown)) { + const Animation::CatalogEntry* ce = m_animCatalog.FindEntry(p_msg.animIndex); + const AnimInfo* ai = ce ? m_animCatalog.GetAnimInfo(p_msg.animIndex) : nullptr; + if (ai) { + m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId); + } + } + + // If local player's pending interest matches, clear it (host has responded) + if (m_localPendingAnimInterest >= 0 && static_cast(m_localPendingAnimInterest) == p_msg.animIndex) { + m_localPendingAnimInterest = -1; + } + + // Stop local player's animation if their session was cleared + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle && + localAnimBefore != Animation::ANIM_INDEX_NONE) { + StopScenePlayback(localAnimBefore, true); + } + + // Stop observer playback when the observed session is cleared + if (m_playingAnims.count(p_msg.animIndex) && p_msg.state == 0) { + StopScenePlayback(p_msg.animIndex, true); + } + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::HandleAnimStart(const AnimStartMsg& p_msg) +{ + if (IsHost()) { + return; // Host handles locally in BroadcastAnimStart + } + + m_animCoordinator.ApplyAnimStart(p_msg.animIndex); + HandleAnimStartLocally(p_msg.animIndex, m_animCoordinator.IsLocalPlayerInSession(p_msg.animIndex), p_msg.eventId); + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession, uint64_t p_eventId) +{ + auto abortSession = [&]() { + // Observers must not abort the authoritative session — only participants may do that + if (p_localInSession) { + if (IsHost()) { + m_animSessionHost.EraseSession(p_animIndex); + BroadcastAnimUpdate(p_animIndex); + } + m_animCoordinator.ResetLocalState(); + } + m_animStateDirty = true; + }; + + const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex); + if (!animInfo) { + abortSession(); + return; + } + + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (!entry) { + abortSession(); + return; + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (p_localInSession && (!cam || !cam->GetDisplayROI())) { + abortSession(); + return; + } + + const Animation::SessionView* view = m_animCoordinator.GetSessionView(p_animIndex); + std::vector slotChars = Animation::SessionHost::ComputeSlotCharIndices(entry); + + bool observerMode = !p_localInSession; + + // Build participants: local player first (if participating), then remotes + int8_t localCharIndex = -1; + std::vector participants; + + if (view) { + uint8_t count = view->slotCount < (uint8_t) slotChars.size() ? view->slotCount : (uint8_t) slotChars.size(); + for (uint8_t i = 0; i < count; i++) { + uint32_t peerId = view->peerSlots[i]; + if (peerId == 0) { + continue; + } + + if (peerId == m_localPeerId) { + localCharIndex = slotChars[i]; + continue; + } + + auto it = m_remotePlayers.find(peerId); + if (it == m_remotePlayers.end() || !it->second->GetROI()) { + continue; + } + + Animation::ParticipantROI rp; + rp.roi = it->second->GetROI(); + rp.vehicleROI = it->second->GetRideVehicleROI(); + rp.charIndex = slotChars[i]; + participants.push_back(rp); + + // Lock performers to prevent network updates from fighting animation + if (!rp.IsSpectator()) { + it->second->LockForAnimation(p_animIndex); + } + } + } + + // Insert local player at index 0 only when participating + if (!observerMode) { + Animation::ParticipantROI local; + local.roi = cam->GetDisplayROI(); + local.vehicleROI = cam->GetRideVehicleROI(); + local.charIndex = localCharIndex; + participants.insert(participants.begin(), local); + } + + if (participants.empty()) { + abortSession(); + return; + } + + auto scenePlayer = std::make_unique(); + scenePlayer->SetLoader(&m_animLoader); + + if (!observerMode) { + bool localIsPerformer = (localCharIndex >= 0); + cam->SetAnimPlaying(true, localIsPerformer, [this, p_animIndex]() { + auto it = m_playingAnims.find(p_animIndex); + if (it != m_playingAnims.end()) { + it->second->Stop(); + } + }); + } + + scenePlayer->Play( + animInfo, + entry->worldId, + entry->category, + participants.data(), + (uint8_t) participants.size(), + observerMode + ); + + if (!scenePlayer->IsPlaying()) { + if (!observerMode) { + cam->SetAnimPlaying(false); + } + UnlockRemotesForAnim(p_animIndex); + abortSession(); + return; + } + + m_playingAnims[p_animIndex] = std::move(scenePlayer); + + // Cache completion JSON for local firing when ScenePlayer finishes. + // Must happen before RemoveSession which destroys the session view. + if (!observerMode && m_callbacks) { + m_pendingCompletionJson[p_animIndex] = BuildCompletionJson(p_animIndex, p_eventId); + } + + // Session data has been captured — free the animation for reuse so all clients + // (not just the host) show it as available immediately after launch. + m_animCoordinator.RemoveSession(p_animIndex); + + m_localPendingAnimInterest = -1; + m_animStateDirty = true; +} + +std::string NetworkManager::BuildCompletionJson(uint16_t p_animIndex, uint64_t p_eventId) +{ + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + const Animation::SessionView* view = m_animCoordinator.GetSessionView(p_animIndex); + if (!entry || !view) { + return {}; + } + + std::vector slotChars = Animation::SessionHost::ComputeSlotCharIndices(entry); + uint8_t count = view->slotCount < static_cast(slotChars.size()) ? view->slotCount + : static_cast(slotChars.size()); + + char eventIdHex[17]; + SDL_snprintf( + eventIdHex, + sizeof(eventIdHex), + "%08x%08x", + static_cast(p_eventId >> 32), + static_cast(p_eventId & 0xFFFFFFFF) + ); + + std::string json = "{\"eventId\":\""; + json += eventIdHex; + json += "\",\"animIndex\":"; + json += std::to_string(p_animIndex); + json += ",\"participants\":["; + + // Emit local player first so frontend can rely on participants[0] being self + bool first = true; + auto appendParticipant = [&](uint32_t peerId, uint8_t slotIndex) { + int8_t charIndex; + char displayName[USERNAME_BUFFER_SIZE] = {}; + + if (Animation::SessionSlot::IsSpectatorCharIndex(slotChars[slotIndex])) { + // Resolve spectator's actual character from their display actor + if (peerId == m_localPeerId) { + ThirdPersonCamera::Controller* cam = GetCamera(); + charIndex = cam ? Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()) : -1; + } + else { + auto it = m_remotePlayers.find(peerId); + charIndex = it != m_remotePlayers.end() + ? Animation::Catalog::DisplayActorToCharacterIndex(it->second->GetDisplayActorIndex()) + : -1; + } + } + else { + charIndex = slotChars[slotIndex]; + } + + if (peerId == m_localPeerId) { + EncodeUsername(displayName); + } + else { + auto it = m_remotePlayers.find(peerId); + if (it != m_remotePlayers.end()) { + SDL_strlcpy(displayName, it->second->GetDisplayName(), sizeof(displayName)); + } + } + + if (!first) { + json += ','; + } + first = false; + json += "{\"charIndex\":"; + json += std::to_string(static_cast(charIndex)); + json += ",\"displayName\":\""; + json += displayName; + json += "\"}"; + }; + + // Local player first + for (uint8_t i = 0; i < count; i++) { + if (view->peerSlots[i] == m_localPeerId) { + appendParticipant(m_localPeerId, i); + break; + } + } + // Then remote players + for (uint8_t i = 0; i < count; i++) { + uint32_t peerId = view->peerSlots[i]; + if (peerId != 0 && peerId != m_localPeerId) { + appendParticipant(peerId, i); + } + } + + json += "]}"; + return json; +} + +AnimUpdateMsg NetworkManager::BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target) +{ + AnimUpdateMsg msg{}; + msg.header = MakeHeader(MSG_ANIM_UPDATE, p_target); + msg.animIndex = p_animIndex; + + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (session) { + msg.state = static_cast(session->state); + msg.countdownMs = Animation::SessionHost::ComputeCountdownMs(*session, SDL_GetTicks()); + msg.slotCount = static_cast(session->slots.size() < 8 ? session->slots.size() : 8); + for (uint8_t i = 0; i < msg.slotCount; i++) { + msg.slots[i].peerId = session->slots[i].peerId; + } + } + // else: zero-initialized = cleared state + return msg; +} + +void NetworkManager::BroadcastAnimUpdate(uint16_t p_animIndex) +{ + AnimUpdateMsg msg = BuildAnimUpdateMsg(p_animIndex, TARGET_BROADCAST); + SendMessage(msg); + + // Also update local coordinator + uint32_t slots[8]; + ExtractSlotPeerIds(msg, slots); + m_animCoordinator.ApplySessionUpdate(msg.animIndex, msg.state, msg.countdownMs, slots, msg.slotCount); +} + +void NetworkManager::SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId) +{ + SendMessage(BuildAnimUpdateMsg(p_animIndex, p_targetPeerId)); +} + +void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex, uint64_t p_eventId) +{ + AnimStartMsg msg{}; + msg.header = MakeHeader(MSG_ANIM_START, TARGET_BROADCAST); + msg.animIndex = p_animIndex; + msg.eventId = p_eventId; + SendMessage(msg); + + // Also update local coordinator + m_animCoordinator.ApplyAnimStart(p_animIndex); +} + +bool NetworkManager::IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const +{ + if (p_peerId == m_localPeerId) { + return m_locationProximity.IsAtLocation(p_location); + } + auto it = m_remotePlayers.find(p_peerId); + if (it != m_remotePlayers.end()) { + return it->second->IsAtLocation(p_location); + } + return false; +} + +bool NetworkManager::GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const +{ + if (p_peerId == m_localPeerId) { + LegoPathActor* userActor = UserActor(); + if (userActor && userActor->GetROI()) { + const float* pos = userActor->GetROI()->GetWorldPosition(); + p_x = pos[0]; + p_z = pos[2]; + return true; + } + return false; + } + auto it = m_remotePlayers.find(p_peerId); + if (it == m_remotePlayers.end() || !it->second->HasReceivedUpdate()) { + return false; + } + it->second->GetTargetPosition(p_x, p_z); + return true; +} + +bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const +{ + if (p_peerId == 0) { + return false; + } + if (p_peerId == m_localPeerId) { + return true; + } + auto it = m_remotePlayers.find(p_peerId); + if (it == m_remotePlayers.end() || it->second->GetWorldId() != (int8_t) LegoOmni::e_act1 || + !it->second->HasReceivedUpdate()) { + return false; + } + float px, pz; + it->second->GetTargetPosition(px, pz); + float dx = px - p_refX; + float dz = pz - p_refZ; + return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ; +} + +uint8_t NetworkManager::GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const +{ + if (p_peerId == m_localPeerId) { + ThirdPersonCamera::Controller* cam = GetCamera(); + return cam ? Animation::Catalog::GetVehicleState(p_charIndex, cam->GetRideVehicleROI()) + : Animation::Catalog::e_onFoot; + } + auto it = m_remotePlayers.find(p_peerId); + if (it == m_remotePlayers.end()) { + return Animation::Catalog::e_onFoot; + } + return Animation::Catalog::GetVehicleState(p_charIndex, it->second->GetRideVehicleROI()); +} + +bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex) +{ + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (!session) { + return false; + } + + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + if (!entry) { + return false; + } + + if (entry->location >= 0) { + // Cam anim: all participants must be at the specific location + for (const auto& slot : session->slots) { + if (slot.peerId == 0) { + continue; + } + if (!IsPeerAtLocation(slot.peerId, entry->location)) { + return false; + } + } + return true; + } + + // NPC anim: all participants must be within NPC_ANIM_PROXIMITY of each other + float firstX = 0, firstZ = 0; + bool hasFirst = false; + + for (const auto& slot : session->slots) { + if (slot.peerId == 0) { + continue; + } + + float px, pz; + if (!GetPeerPosition(slot.peerId, px, pz)) { + continue; // Position unknown — don't block + } + + if (!hasFirst) { + firstX = px; + firstZ = pz; + hasFirst = true; + } + else { + float dx = px - firstX; + float dz = pz - firstZ; + if ((dx * dx + dz * dz) > (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { + return false; + } + } + } + + return true; +} + +void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) +{ + uint32_t targetPeerId = p_msg.targetPeerId; + + // Check if the target is a remote player on this client. + // Only play effects here -- do NOT modify the remote player's customize state. + // State changes come exclusively through UpdateFromNetwork (from the target's + // authoritative PlayerStateMsg), which prevents flip-flop from stale state messages. + // Note: sound/mood feedback uses the old state (before the authoritative update arrives), + // so the played sound may lag one step behind. This is an accepted tradeoff. + auto it = m_remotePlayers.find(targetPeerId); + if (it != m_remotePlayers.end()) { + if (it->second->GetROI()) { + Common::CharacterCustomizer::PlayClickSound( + it->second->GetROI(), + it->second->GetCustomizeState(), + p_msg.changeType == CHANGE_MOOD + ); + if (!it->second->IsMoving() && !it->second->IsExtraAnimBlocking() && !it->second->IsAnimationLocked()) { + it->second->StopClickAnimation(); + MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation( + it->second->GetROI(), + it->second->GetCustomizeState() + ); + it->second->SetClickAnimObjectId(clickAnimId); + } + } + return; + } + + // Check if the target is the local player + if (targetPeerId == m_localPeerId) { + // Reject remote customization if not allowed + if (p_msg.header.peerId != m_localPeerId && !m_localAllowRemoteCustomize) { + return; + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (!cam) { + return; + } + + // ApplyCustomizeChange handles null display ROI (advances state without visual) + cam->ApplyCustomizeChange(p_msg.changeType, p_msg.partIndex); + + // Use display ROI for effects in 3rd person, native ROI in 1st person + LegoROI* effectROI = cam->GetDisplayROI(); + if (!effectROI && UserActor()) { + effectROI = UserActor()->GetROI(); + } + + if (effectROI) { + Common::CharacterCustomizer::PlayClickSound( + effectROI, + cam->GetCustomizeState(), + p_msg.changeType == CHANGE_MOOD + ); + + // Only play click animation in 3rd person (not during multi-part emote or animation performance; spectators + // allowed) + if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsExtraAnimBlocking() && + !(cam->IsAnimPlaying() && cam->IsAnimLockDisplay())) { + cam->StopClickAnimation(); + MxU32 clickAnimId = + Common::CharacterCustomizer::PlayClickAnimation(cam->GetDisplayROI(), cam->GetCustomizeState()); + cam->SetClickAnimObjectId(clickAnimId); + } + } + } +} + +// Helper: append a JSON-escaped string value (assumes no control chars in input) +static void JsonAppendString(std::string& p_out, const char* p_str) +{ + p_out += '"'; + p_out += p_str; + p_out += '"'; +} + +static void BuildAnimationJson( + std::string& p_json, + const Animation::EligibilityInfo& p_info, + const AnimInfo* p_animInfo, + uint8_t p_sessionState, + uint16_t p_countdownMs, + bool p_localInSession, + int8_t p_localCharIndex +) +{ + p_json += "{\"animIndex\":"; + p_json += std::to_string(p_info.animIndex); + p_json += ",\"name\":"; + JsonAppendString(p_json, p_animInfo->m_name ? p_animInfo->m_name : ""); + p_json += ",\"category\":"; + p_json += std::to_string(static_cast(p_info.entry->category)); + p_json += ",\"eligible\":"; + p_json += p_info.eligible ? "true" : "false"; + p_json += ",\"atLocation\":"; + p_json += p_info.atLocation ? "true" : "false"; + p_json += ",\"sessionState\":"; + p_json += std::to_string(p_sessionState); + p_json += ",\"countdownMs\":"; + p_json += std::to_string(p_countdownMs); + p_json += ",\"localInSession\":"; + p_json += p_localInSession ? "true" : "false"; + + // canJoin: local player could fill an unfilled slot (checked via bitmasks) + bool canJoin = false; + if (!p_localInSession && p_sessionState >= 1 && p_localCharIndex >= 0) { + uint64_t localBit = uint64_t(1) << p_localCharIndex; + if ((p_info.entry->performerMask & localBit)) { + // Find this performer's slot index and check if unfilled + uint8_t slotIdx = 0; + for (int8_t bit = 0; bit < p_localCharIndex; bit++) { + if (p_info.entry->performerMask & (uint64_t(1) << bit)) { + slotIdx++; + } + } + if (slotIdx < p_info.slots.size() && !p_info.slots[slotIdx].filled) { + canJoin = true; + } + } + else { + // Check spectator slot (last slot): unfilled and player is eligible + if (!p_info.slots.empty() && !p_info.slots.back().filled && + Animation::Catalog::CanParticipateChar(p_info.entry, p_localCharIndex)) { + canJoin = true; + } + } + } + p_json += ",\"canJoin\":"; + p_json += canJoin ? "true" : "false"; + + p_json += ",\"slots\":["; + for (size_t s = 0; s < p_info.slots.size(); s++) { + const auto& slot = p_info.slots[s]; + if (s > 0) { + p_json += ','; + } + p_json += "{\"names\":["; + for (size_t n = 0; n < slot.names.size(); n++) { + if (n > 0) { + p_json += ','; + } + JsonAppendString(p_json, slot.names[n]); + } + p_json += "],\"filled\":"; + p_json += slot.filled ? "true" : "false"; + p_json += '}'; + } + p_json += "]}"; +} + +void NetworkManager::PushAnimationState() +{ + ThirdPersonCamera::Controller* cam = GetCamera(); + if (!cam || !cam->GetDisplayROI()) { + // Camera unavailable — push idle state so the frontend clears any countdown/session UI + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON); + } + return; + } + + const auto& locations = m_locationProximity.GetLocations(); + uint8_t displayActorIndex = cam->GetDisplayActorIndex(); + int8_t localCharIndex = Animation::Catalog::DisplayActorToCharacterIndex(displayActorIndex); + + LegoPathActor* userActor = UserActor(); + if (!userActor || !userActor->GetROI()) { + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON); + } + return; + } + const float* localPos = userActor->GetROI()->GetWorldPosition(); + float localX = localPos[0], localZ = localPos[2]; + + uint8_t localVehicleState = Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI()); + + // Build proximity character indices and vehicle state (for NPC anims — position-based, not location-based) + std::vector proximityCharIndices; + std::vector proximityVehicleState; + proximityCharIndices.push_back(localCharIndex); + proximityVehicleState.push_back(localVehicleState); + + for (const auto& [peerId, player] : m_remotePlayers) { + if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { + continue; + } + // Exact NPC_ANIM_PROXIMITY radius for triggering eligibility + // (tighter than IsPeerNearby's NPC_ANIM_NEARBY_RADIUS_SQ used for session visibility) + const float* rpos = player->GetROI()->GetWorldPosition(); + float dx = rpos[0] - localX; + float dz = rpos[2] - localZ; + if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); + proximityCharIndices.push_back(charIdx); + proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI())); + } + } + + // Compute eligibility across all overlapping locations. + // Each call returns NPC anims + cam anims for that specific location. + // NPC anims are identical across calls (same proximityChars), so we deduplicate by animIndex. + std::vector eligibility; + std::set seenAnimIndices; + + // If at no location, still process once with -1 to get NPC anims + std::vector locationsToProcess = locations.empty() ? std::vector{int16_t(-1)} : locations; + + for (int16_t loc : locationsToProcess) { + // Build per-location character indices and vehicle state (for cam anims at this location) + std::vector locationCharIndices; + std::vector locationVehicleState; + locationCharIndices.push_back(localCharIndex); + locationVehicleState.push_back(localVehicleState); + + for (const auto& [peerId, player] : m_remotePlayers) { + if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { + continue; + } + if (player->IsAtLocation(loc)) { + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); + locationCharIndices.push_back(charIdx); + locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI()) + ); + } + } + + auto locEligibility = m_animCoordinator.ComputeEligibility( + loc, + locationCharIndices.data(), + locationVehicleState.data(), + static_cast(locationCharIndices.size()), + proximityCharIndices.data(), + proximityVehicleState.data(), + static_cast(proximityCharIndices.size()) + ); + + for (auto& info : locEligibility) { + if (seenAnimIndices.insert(info.animIndex).second) { + eligibility.push_back(std::move(info)); + } + } + } + + // Build JSON + std::string json; + json.reserve(2048); + json += "{\"locations\":["; + for (size_t i = 0; i < locations.size(); i++) { + if (i > 0) { + json += ','; + } + json += std::to_string(locations[i]); + } + json += "],\"state\":"; + json += std::to_string(static_cast(m_animCoordinator.GetState())); + json += ",\"currentAnimIndex\":"; + json += std::to_string(m_animCoordinator.GetCurrentAnimIndex()); + json += ",\"pendingInterest\":"; + json += std::to_string(m_localPendingAnimInterest); + json += ",\"animations\":["; + + bool firstAnim = true; + for (size_t i = 0; i < eligibility.size(); i++) { + const auto& info = eligibility[i]; + const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(info.animIndex); + if (!animInfo) { + continue; + } + + if (!firstAnim) { + json += ','; + } + firstAnim = false; + + // Session state: host computes live countdown, clients derive from countdownEndTime + uint8_t sessionState = 0; + uint16_t countdownMs = 0; + if (IsHost()) { + const Animation::AnimSession* hostSession = m_animSessionHost.FindSession(info.animIndex); + if (hostSession) { + sessionState = static_cast(hostSession->state); + countdownMs = Animation::SessionHost::ComputeCountdownMs(*hostSession, SDL_GetTicks()); + } + } + else { + const Animation::SessionView* sv = m_animCoordinator.GetSessionView(info.animIndex); + if (sv) { + sessionState = static_cast(sv->state); + if (sv->state == Animation::CoordinationState::e_countdown && sv->countdownEndTime > 0) { + uint32_t now = SDL_GetTicks(); + countdownMs = (now < sv->countdownEndTime) ? static_cast(sv->countdownEndTime - now) : 0; + } + else { + countdownMs = sv->countdownMs; + } + } + } + + bool localInSession = m_animCoordinator.IsLocalPlayerInSession(info.animIndex); + + // Suppress session display if local player is not in the session and no + // session participant is nearby — prevents stale "Join!" for far-away sessions + if (sessionState > 0 && !localInSession) { + bool anyParticipantNearby = false; + + if (IsHost()) { + const Animation::AnimSession* hs = m_animSessionHost.FindSession(info.animIndex); + if (hs) { + for (const auto& slot : hs->slots) { + if (IsPeerNearby(slot.peerId, localX, localZ)) { + anyParticipantNearby = true; + break; + } + } + } + } + else { + const Animation::SessionView* ssv = m_animCoordinator.GetSessionView(info.animIndex); + if (ssv) { + for (uint8_t s = 0; s < ssv->slotCount && !anyParticipantNearby; s++) { + if (IsPeerNearby(ssv->peerSlots[s], localX, localZ)) { + anyParticipantNearby = true; + } + } + } + } + + if (!anyParticipantNearby) { + sessionState = 0; + countdownMs = 0; + } + } + + BuildAnimationJson(json, info, animInfo, sessionState, countdownMs, localInSession, localCharIndex); + } + + json += "]}"; + + if (m_callbacks) { + m_callbacks->OnAnimationsAvailable(json.c_str()); + } +} diff --git a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp new file mode 100644 index 00000000..c6ffb182 --- /dev/null +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -0,0 +1,97 @@ +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer/platforms/emscripten/callbacks.h" + +#include + +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__ diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp new file mode 100644 index 00000000..1ee63978 --- /dev/null +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -0,0 +1,79 @@ +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer.h" +#include "extensions/multiplayer/networkmanager.h" + +#include + +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(index)); + } + } + + EMSCRIPTEN_KEEPALIVE void mp_set_idle_animation(int index) + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestSetIdleAnimation(static_cast(index)); + } + } + + EMSCRIPTEN_KEEPALIVE void mp_trigger_emote(int index) + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestSendEmote(static_cast(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(animIndex)); + } + } + + EMSCRIPTEN_KEEPALIVE void mp_cancel_anim_interest() + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestCancelAnimInterest(); + } + } + +} // extern "C" + +#endif diff --git a/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp b/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp new file mode 100644 index 00000000..423c0701 --- /dev/null +++ b/extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp @@ -0,0 +1,213 @@ +#ifdef __EMSCRIPTEN__ + +#include "extensions/multiplayer/platforms/emscripten/websockettransport.h" + +#include +#include + +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 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__ diff --git a/extensions/src/multiplayer/platforms/native/lwstransport.cpp b/extensions/src/multiplayer/platforms/native/lwstransport.cpp new file mode 100644 index 00000000..7c35a6c0 --- /dev/null +++ b/extensions/src/multiplayer/platforms/native/lwstransport.cpp @@ -0,0 +1,289 @@ +#ifndef __EMSCRIPTEN__ + +#include "extensions/multiplayer/platforms/native/lwstransport.h" + +#include +#include +#include + +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(lws_get_opaque_user_data(p_wsi)); + if (transport) { + return transport->HandleLwsEvent(p_wsi, static_cast(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 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 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 p_callback) +{ + if (!m_context) { + return 0; + } + + std::deque> 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(p_in), static_cast(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 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(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__ diff --git a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp new file mode 100644 index 00000000..c1e35627 --- /dev/null +++ b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp @@ -0,0 +1,67 @@ +#ifndef __EMSCRIPTEN__ + +#include "extensions/multiplayer/platforms/native/nativecallbacks.h" + +#include + +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__ diff --git a/extensions/src/multiplayer/protocol.cpp b/extensions/src/multiplayer/protocol.cpp new file mode 100644 index 00000000..6ed8ab36 --- /dev/null +++ b/extensions/src/multiplayer/protocol.cpp @@ -0,0 +1,32 @@ +#include "extensions/multiplayer/protocol.h" + +#include "legogamestate.h" +#include "misc.h" + +#include + +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 diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp new file mode 100644 index 00000000..2d2f51fc --- /dev/null +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -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 +#include +#include +#include + +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(); +} diff --git a/extensions/src/multiplayer/server/.gitignore b/extensions/src/multiplayer/server/.gitignore new file mode 100644 index 00000000..41a25789 --- /dev/null +++ b/extensions/src/multiplayer/server/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +node_modules/ diff --git a/extensions/src/multiplayer/server/cors.ts b/extensions/src/multiplayer/server/cors.ts new file mode 100644 index 00000000..8db1ee3e --- /dev/null +++ b/extensions/src/multiplayer/server/cors.ts @@ -0,0 +1,6 @@ +export const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin" : "*", + "Access-Control-Allow-Methods" : "GET, POST, OPTIONS", + "Access-Control-Allow-Headers" : "Content-Type", + "Cross-Origin-Resource-Policy" : "cross-origin", +}; diff --git a/extensions/src/multiplayer/server/gameroom.ts b/extensions/src/multiplayer/server/gameroom.ts new file mode 100644 index 00000000..e8d3cea3 --- /dev/null +++ b/extensions/src/multiplayer/server/gameroom.ts @@ -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(); + 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("isPublic")) ?? true; + this.roomId = + (await state.storage.get("roomId")) ?? null; + this.maxPlayers = + (await state.storage.get("maxPlayers")) ?? 5; + }); + } + + async fetch(request: Request): Promise { + // 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 { + 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 { + 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, + }), + }) + ); + } +} diff --git a/extensions/src/multiplayer/server/package-lock.json b/extensions/src/multiplayer/server/package-lock.json new file mode 100644 index 00000000..519d488b --- /dev/null +++ b/extensions/src/multiplayer/server/package-lock.json @@ -0,0 +1,1323 @@ +{ + "name": "server", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "wrangler": "^4" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz", + "integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20260218.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260305.0.tgz", + "integrity": "sha512-chhKOpymo0Eh9J3nymrauMqKGboCc4uz/j0gA1G4gioMnKsN2ZDKJ+qjRZDnCoVGy8u2C4pxlmyIfsXCAfIzhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260305.0.tgz", + "integrity": "sha512-K9aG2OQk5bBfOP+fyGPqLcqZ9OR3ra6uwnxJ8f2mveq2A2LsCI7ZeGxQiAj75Ti80ytH/gJffZIx4Np2JtU3aQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260305.0.tgz", + "integrity": "sha512-tt7XUoIw/cYFeGbkPkcZ6XX1aZm26Aju/4ih+DXxOosbBeGshFSrNJDBfAKKOvkjsAZymJ+WWVDBU+hmNaGfwA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260305.0.tgz", + "integrity": "sha512-72QTkY5EzylmvCZ8ZTrnJ9DctmQsfSof1OKyOWqu/pv/B2yACfuPMikq8RpPxvVu7hhS0ztGP6ZvXz72Htq4Zg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260305.0.tgz", + "integrity": "sha512-BA0uaQPOaI2F6mJtBDqplGnQQhpXCzwEMI33p/TnDxtSk9u8CGIfBFuI6uqo8mJ6ijIaPjeBLGOn2CiRMET4qg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", + "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260305.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260305.0.tgz", + "integrity": "sha512-jVhtKJtiwaZa3rI+WgoLvSJmEazDsoUmAPYRUmEe2VO6VSbvkhbnDRm+dsPbYRatgNIExwrpqG1rv96jHiSb0w==", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260305.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "optional": true + }, + "node_modules/undici": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260305.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260305.0.tgz", + "integrity": "sha512-JkhfCLU+w+KbQmZ9k49IcDYc78GBo7eG8Mir8E2+KVjR7otQAmpcLlsous09YLh8WQ3Bt3Mi6/WMStvMAPukeA==", + "hasInstallScript": true, + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260305.0", + "@cloudflare/workerd-darwin-arm64": "1.20260305.0", + "@cloudflare/workerd-linux-64": "1.20260305.0", + "@cloudflare/workerd-linux-arm64": "1.20260305.0", + "@cloudflare/workerd-windows-64": "1.20260305.0" + } + }, + "node_modules/wrangler": { + "version": "4.69.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.69.0.tgz", + "integrity": "sha512-EmVfIM65I5b4ITHe3Y9R7zQyf4NUBQ1leStakMlWiVR9n6VlDwuEltyQI2l3i0JciDnWyR3uqe+T6C08ivniTQ==", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.14.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260305.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260305.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260305.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/extensions/src/multiplayer/server/package.json b/extensions/src/multiplayer/server/package.json new file mode 100644 index 00000000..3b49dc95 --- /dev/null +++ b/extensions/src/multiplayer/server/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "dev": "wrangler dev --port 8787", + "deploy": "wrangler deploy" + }, + "dependencies": { + "wrangler": "^4" + } +} \ No newline at end of file diff --git a/extensions/src/multiplayer/server/protocol.ts b/extensions/src/multiplayer/server/protocol.ts new file mode 100644 index 00000000..cbc2d976 --- /dev/null +++ b/extensions/src/multiplayer/server/protocol.ts @@ -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); +} diff --git a/extensions/src/multiplayer/server/relay.ts b/extensions/src/multiplayer/server/relay.ts new file mode 100644 index 00000000..1bdbd62c --- /dev/null +++ b/extensions/src/multiplayer/server/relay.ts @@ -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 { + 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 }); + }, +}; diff --git a/extensions/src/multiplayer/server/roomregistry.ts b/extensions/src/multiplayer/server/roomregistry.ts new file mode 100644 index 00000000..660cc53b --- /dev/null +++ b/extensions/src/multiplayer/server/roomregistry.ts @@ -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 { + 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 { + 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 { + 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(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 { + 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 { + const map = await this.state.storage.list({ + prefix: "room:", + }); + return [...map.values()]; + } + + private async ensureAlarm(): Promise { + const currentAlarm = await this.state.storage.getAlarm(); + if (currentAlarm === null) { + await this.state.storage.setAlarm( + Date.now() + ALARM_INTERVAL_MS + ); + } + } +} diff --git a/extensions/src/multiplayer/server/wrangler.toml b/extensions/src/multiplayer/server/wrangler.toml new file mode 100644 index 00000000..5cd7f32e --- /dev/null +++ b/extensions/src/multiplayer/server/wrangler.toml @@ -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"] diff --git a/extensions/src/multiplayer/sireader.cpp b/extensions/src/multiplayer/sireader.cpp new file mode 100644 index 00000000..d5635a2e --- /dev/null +++ b/extensions/src/multiplayer/sireader.cpp @@ -0,0 +1,163 @@ +#include "extensions/multiplayer/sireader.h" + +#include "extensions/common/pathutils.h" +#include "mxautolock.h" + +#include +#include +#include + +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(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(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(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; +} diff --git a/extensions/src/multiplayer/worldstatesync.cpp b/extensions/src/multiplayer/worldstatesync.cpp new file mode 100644 index 00000000..eaeab4bf --- /dev/null +++ b/extensions/src/multiplayer/worldstatesync.cpp @@ -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 +#include + +extern MxU8 g_counters[]; +extern MxU8 g_buildingInfoDownshift[]; + +using namespace Multiplayer; + +template +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 +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 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(); + } + } + } +} diff --git a/extensions/src/siloader.cpp b/extensions/src/siloader.cpp index 937ecdaf..e09981a9 100644 --- a/extensions/src/siloader.cpp +++ b/extensions/src/siloader.cpp @@ -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 SiLoaderExt::HandleFind(StreamObject p_object, LegoWorld* world) +std::optional 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 SiLoaderExt::HandleRemove(StreamObject p_object, LegoWorld* world) +std::optional 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_}); } } } diff --git a/extensions/src/textureloader.cpp b/extensions/src/textureloader.cpp index 41550a1b..ba65229b 100644 --- a/extensions/src/textureloader.cpp +++ b/extensions/src/textureloader.cpp @@ -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) { diff --git a/extensions/src/thirdpersoncamera/controller.cpp b/extensions/src/thirdpersoncamera/controller.cpp index 976bd106..f4f5d460 100644 --- a/extensions/src/thirdpersoncamera/controller.cpp +++ b/extensions/src/thirdpersoncamera/controller.cpp @@ -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(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); + } } } diff --git a/miniwin/src/d3drm/backends/software/renderer.cpp b/miniwin/src/d3drm/backends/software/renderer.cpp index 93a80a2e..b945fbc9 100644 --- a/miniwin/src/d3drm/backends/software/renderer.cpp +++ b/miniwin/src/d3drm/backends/software/renderer.cpp @@ -465,54 +465,62 @@ void Direct3DRMSoftwareRenderer::DrawTriangleProjected( Uint8* pixelAddr = pixels + y * pitch + x * m_bytesPerPixel; Uint32 finalColor; - if (appearance.color.a == 255) { - zref = z; + Uint8 alpha = appearance.color.a; - if (texels) { - // Perspective correct interpolate texture coords - float one_over_w = left.one_over_w + t * (right.one_over_w - left.one_over_w); - float u_over_w = left.u_over_w + t * (right.u_over_w - left.u_over_w); - float v_over_w = left.v_over_w + t * (right.v_over_w - left.v_over_w); + if (texels) { + // Perspective correct interpolate texture coords + float one_over_w = left.one_over_w + t * (right.one_over_w - left.one_over_w); + float u_over_w = left.u_over_w + t * (right.u_over_w - left.u_over_w); + float v_over_w = left.v_over_w + t * (right.v_over_w - left.v_over_w); - float inv_w = 1.0f / one_over_w; - float u = u_over_w * inv_w; - float v = v_over_w * inv_w; + float inv_w = 1.0f / one_over_w; + float u = u_over_w * inv_w; + float v = v_over_w * inv_w; - // Tile textures - u -= std::floor(u); - v -= std::floor(v); + // Tile textures + u -= std::floor(u); + v -= std::floor(v); - int texX = static_cast(u * texWidthScale); - int texY = static_cast(v * texHeightScale); + int texX = static_cast(u * texWidthScale); + int texY = static_cast(v * texHeightScale); - Uint8* texelAddr = texels + texY * texturePitch + texX * m_bytesPerPixel; + Uint8* texelAddr = texels + texY * texturePitch + texX * m_bytesPerPixel; - Uint32 texelColor; - switch (m_bytesPerPixel) { - case 1: - texelColor = *texelAddr; - break; - case 2: - texelColor = *(Uint16*) texelAddr; - break; - case 4: - texelColor = *(Uint32*) texelAddr; - break; - } - - Uint8 tr, tg, tb, ta; - SDL_GetRGBA(texelColor, m_format, m_palette, &tr, &tg, &tb, &ta); - - // Multiply vertex color by texel color - r = (r * tr + 127) / 255; - g = (g * tg + 127) / 255; - b = (b * tb + 127) / 255; + Uint32 texelColor; + switch (m_bytesPerPixel) { + case 1: + texelColor = *texelAddr; + break; + case 2: + texelColor = *(Uint16*) texelAddr; + break; + case 4: + texelColor = *(Uint32*) texelAddr; + break; } + Uint8 tr, tg, tb, ta; + SDL_GetRGBA(texelColor, m_format, m_palette, &tr, &tg, &tb, &ta); + + // Multiply vertex color by texel color + r = (r * tr + 127) / 255; + g = (g * tg + 127) / 255; + b = (b * tb + 127) / 255; + + // Use texel alpha for per-pixel transparency + alpha = ta; + } + + if (alpha == 0) { + continue; + } + + if (alpha == 255) { + zref = z; finalColor = SDL_MapRGBA(m_format, m_palette, r, g, b, 255); } else { - finalColor = BlendPixel(pixelAddr, r, g, b, appearance.color.a); + finalColor = BlendPixel(pixelAddr, r, g, b, alpha); } switch (m_bytesPerPixel) { diff --git a/miniwin/src/d3drm/d3drmtexture.cpp b/miniwin/src/d3drm/d3drmtexture.cpp index a5d63588..cc4dbe2b 100644 --- a/miniwin/src/d3drm/d3drmtexture.cpp +++ b/miniwin/src/d3drm/d3drmtexture.cpp @@ -1,9 +1,49 @@ #include "d3drmtexture_impl.h" +#include "ddsurface_impl.h" #include "miniwin.h" -Direct3DRMTextureImpl::Direct3DRMTextureImpl(D3DRMIMAGE* image) +Direct3DRMTextureImpl::Direct3DRMTextureImpl(D3DRMIMAGE* image) : m_holdsRef(true) { - MINIWIN_NOT_IMPLEMENTED(); + if (!image || !image->buffer1 || image->width == 0 || image->height == 0) { + m_surface = nullptr; + return; + } + + auto* ddsurface = new DirectDrawSurfaceImpl(image->width, image->height, SDL_PIXELFORMAT_RGBA32); + if (!ddsurface->m_surface) { + ddsurface->Release(); + m_surface = nullptr; + return; + } + + uint8_t* dst = static_cast(ddsurface->m_surface->pixels); + int dstPitch = ddsurface->m_surface->pitch; + + if (!image->rgb && image->palette && image->palette_size > 0) { + uint8_t* indices = static_cast(image->buffer1); + for (int y = 0; y < image->height; y++) { + for (int x = 0; x < image->width; x++) { + uint8_t idx = indices[y * image->bytes_per_line + x]; + uint8_t* pixel = dst + y * dstPitch + x * 4; + if (idx < image->palette_size) { + const D3DRMPALETTEENTRY& e = image->palette[idx]; + pixel[0] = e.red; + pixel[1] = e.green; + pixel[2] = e.blue; + // Palette index 0 is transparent by convention (color key) + pixel[3] = (idx == 0) ? 0 : 255; + } + else { + pixel[0] = pixel[1] = pixel[2] = pixel[3] = 0; + } + } + } + } + else { + SDL_memset(dst, 0, dstPitch * image->height); + } + + m_surface = static_cast(ddsurface); } Direct3DRMTextureImpl::Direct3DRMTextureImpl(IDirectDrawSurface* surface, bool holdsRef) diff --git a/tools/ncc/skip.yml b/tools/ncc/skip.yml index 271a4271..3e46aa5a 100644 --- a/tools/ncc/skip.yml +++ b/tools/ncc/skip.yml @@ -79,4 +79,6 @@ SDL_MouseID_v: "SDL-based name" SDL_JoystickID_v: "SDL-based name" SDL_TouchID_v: "SDL-based name" Load: "Not a variable but function name" -HandleCreate: "Not a variable but function name" \ No newline at end of file +HandleCreate: "Not a variable but function name" +HandleBeforeSaveLoad: "Not a variable but function name" +HandleSaveLoaded: "Not a variable but function name" \ No newline at end of file