From 7b114bbe595bcad0e26bbb90d6f99aff7044869d Mon Sep 17 00:00:00 2001 From: foxtacles Date: Sun, 5 Apr 2026 08:13:15 -0700 Subject: [PATCH] Add multiplayer extension (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add multiplayer extension * Fix animation system to work when host is outside ISLE world - Move TickHostSessions outside m_inIsleWorld gate so the host can coordinate animations from any world - Load animation catalog early in HandleCreate so the host can coordinate before entering the ISLE world - Use network-reported positions for remote player location detection instead of requiring spawned ROIs - Always erase sessions at launch — the host's job ends when the animation starts; clients play and complete independently - Replace BroadcastAnimComplete with locally-driven completion callbacks: host generates eventId at launch, clients cache completion JSON at start time, fire it when ScenePlayer finishes - Make StopAnimation only do local cleanup (stop playback, cancel own interest, reset coordinator) without destroying the session host, so other players' sessions survive world transitions - Broadcast state=0 in ResetAnimationState for full teardown paths (shutdown, reconnect, host migration) so clients aren't left with stale session state * Fix use-after-free crash in ScenePlayer when remote player disconnects mid-animation When a remote player's ROI is destroyed (disconnect, timeout, or respawn), notify all active ScenePlayer instances to null out dangling references before the ROI is freed. The animation engine already handles null ROI map entries gracefully, so playback continues for remaining participants. * Fix crash when performer's child ROIs are left dangling in ScenePlayer NotifyROIDestroyed now walks the parent chain to also invalidate child ROIs of the destroyed performer (head, limbs, etc.) that were placed into the roiMap by BuildROIMap. The ancestor walk happens once; all other fields are cleaned with simple pointer equality. * Allow spectator to play click animation during scene playback * Make PTATCAM track spectator ROI instead of camera in ScenePlayer * Only regenerate emscripten version files when git state changes Replace add_custom_target(ALL) with add_custom_command(OUTPUT) so the version script only runs when .git/HEAD or the current branch ref file changes, instead of on every build. * Fix ROI name collision causing dangling pointers in NPC locomotion roiMaps When ScenePlayer created cloned NPC ROIs for cooperative animations, it renamed them to match the original character name and added them to the ViewManager. This created a name collision: two ROIs with the same name. The original game's AppendROIToScene searches by name and stops at the first match, so if a locomotion BuildROIMap ran while the clone existed, it could capture pointers to the clone's child ROIs. When the clone was later destroyed (CleanupProps), those roiMap entries became dangling pointers, crashing in AnimateWithTransform at roi.h:151 (SetVisibility). Fix: use the alias mechanism (already supported by AnimUtils::BuildROIMap) instead of renaming clones. Also unify all ROI name generation behind a shared counter to prevent character manager key collisions. --- 3rdparty/CMakeLists.txt | 62 + 3rdparty/patch_mbedtls_cmake.cmake | 7 + CMake/EmscriptenVersion.cmake | 23 + CMakeLists.txt | 90 +- ISLE/emscripten/events.cpp | 12 + ISLE/emscripten/events.h | 2 + ISLE/isleapp.cpp | 17 + LEGO1/lego/legoomni/include/isle.h | 2 + .../legoomni/include/legoanimationmanager.h | 4 + .../legoomni/include/legobuildingmanager.h | 3 + LEGO1/lego/legoomni/include/legocachsound.h | 4 + .../lego/legoomni/include/legoinputmanager.h | 1 + .../legoomni/include/legomodelpresenter.h | 3 + .../lego/legoomni/include/legoplantmanager.h | 3 + LEGO1/lego/legoomni/include/legoutils.h | 6 +- LEGO1/lego/legoomni/src/actors/ambulance.cpp | 4 + LEGO1/lego/legoomni/src/actors/bike.cpp | 4 + LEGO1/lego/legoomni/src/actors/dunebuggy.cpp | 4 + LEGO1/lego/legoomni/src/actors/isleactor.cpp | 10 +- LEGO1/lego/legoomni/src/actors/towtrack.cpp | 4 + .../src/common/legocharactermanager.cpp | 4 +- .../legoomni/src/common/legogamestate.cpp | 9 + LEGO1/lego/legoomni/src/common/legoutils.cpp | 5 +- LEGO1/lego/legoomni/src/entity/legoentity.cpp | 7 + LEGO1/lego/legoomni/src/entity/legoworld.cpp | 3 + .../legoomni/src/input/legoinputmanager.cpp | 4 +- LEGO1/lego/legoomni/src/main/legomain.cpp | 2 + LEGO1/lego/legoomni/src/worlds/isle.cpp | 6 + .../legoomni/src/worlds/registrationbook.cpp | 2 + docker/relay/Dockerfile | 14 + .../roi-direction-conventions.md | 91 + .../extensions/common/characteranimator.h | 13 +- extensions/include/extensions/extensions.h | 2 +- extensions/include/extensions/fwd.h | 13 + extensions/include/extensions/multiplayer.h | 92 + .../multiplayer/animation/audioplayer.h | 33 + .../multiplayer/animation/catalog.h | 148 + .../multiplayer/animation/coordinator.h | 110 + .../extensions/multiplayer/animation/loader.h | 122 + .../multiplayer/animation/locationproximity.h | 33 + .../multiplayer/animation/phonemeplayer.h | 39 + .../multiplayer/animation/sceneplayer.h | 106 + .../multiplayer/animation/sessionhost.h | 80 + .../extensions/multiplayer/emoteanimhandler.h | 59 + .../include/extensions/multiplayer/mputils.h | 47 + .../multiplayer/namebubblerenderer.h | 50 + .../extensions/multiplayer/networkmanager.h | 257 ++ .../extensions/multiplayer/networktransport.h | 28 + .../multiplayer/platformcallbacks.h | 43 + .../platforms/emscripten/callbacks.h | 23 + .../platforms/emscripten/websockettransport.h | 36 + .../platforms/native/lwstransport.h | 72 + .../platforms/native/nativecallbacks.h | 23 + .../include/extensions/multiplayer/protocol.h | 266 ++ .../extensions/multiplayer/remoteplayer.h | 126 + .../include/extensions/multiplayer/sireader.h | 66 + .../extensions/multiplayer/worldstatesync.h | 78 + extensions/include/extensions/siloader.h | 4 +- .../extensions/thirdpersoncamera/controller.h | 19 +- .../thirdpersoncamera/orbitcamera.h | 4 +- extensions/src/common/characteranimator.cpp | 31 +- extensions/src/common/charactercloner.cpp | 10 +- extensions/src/common/charactercustomizer.cpp | 4 +- extensions/src/common/charactertables.cpp | 8 +- extensions/src/common/pathutils.cpp | 4 +- extensions/src/extensions.cpp | 10 +- extensions/src/multiplayer.cpp | 306 ++ .../src/multiplayer/animation/audioplayer.cpp | 55 + .../src/multiplayer/animation/catalog.cpp | 634 +++++ .../src/multiplayer/animation/coordinator.cpp | 304 ++ .../src/multiplayer/animation/loader.cpp | 441 +++ .../animation/locationproximity.cpp | 54 + .../multiplayer/animation/phonemeplayer.cpp | 218 ++ .../src/multiplayer/animation/sceneplayer.cpp | 691 +++++ .../src/multiplayer/animation/sessionhost.cpp | 310 ++ .../src/multiplayer/emoteanimhandler.cpp | 185 ++ extensions/src/multiplayer/mputils.cpp | 168 ++ .../src/multiplayer/namebubblerenderer.cpp | 330 +++ extensions/src/multiplayer/networkmanager.cpp | 2487 +++++++++++++++++ .../platforms/emscripten/callbacks.cpp | 97 + .../platforms/emscripten/wasm_exports.cpp | 79 + .../emscripten/websockettransport.cpp | 213 ++ .../platforms/native/lwstransport.cpp | 289 ++ .../platforms/native/nativecallbacks.cpp | 67 + extensions/src/multiplayer/protocol.cpp | 32 + extensions/src/multiplayer/remoteplayer.cpp | 441 +++ extensions/src/multiplayer/server/.gitignore | 2 + extensions/src/multiplayer/server/cors.ts | 6 + extensions/src/multiplayer/server/gameroom.ts | 264 ++ .../src/multiplayer/server/package-lock.json | 1323 +++++++++ .../src/multiplayer/server/package.json | 9 + extensions/src/multiplayer/server/protocol.ts | 64 + extensions/src/multiplayer/server/relay.ts | 45 + .../src/multiplayer/server/roomregistry.ts | 137 + .../src/multiplayer/server/wrangler.toml | 22 + extensions/src/multiplayer/sireader.cpp | 163 ++ extensions/src/multiplayer/worldstatesync.cpp | 490 ++++ extensions/src/siloader.cpp | 18 +- extensions/src/textureloader.cpp | 8 +- .../src/thirdpersoncamera/controller.cpp | 35 +- .../src/d3drm/backends/software/renderer.cpp | 82 +- miniwin/src/d3drm/d3drmtexture.cpp | 44 +- tools/ncc/skip.yml | 4 +- 103 files changed, 12465 insertions(+), 123 deletions(-) create mode 100644 3rdparty/patch_mbedtls_cmake.cmake create mode 100644 CMake/EmscriptenVersion.cmake create mode 100644 docker/relay/Dockerfile create mode 100644 extensions/docs/thirdpersoncamera/roi-direction-conventions.md create mode 100644 extensions/include/extensions/multiplayer.h create mode 100644 extensions/include/extensions/multiplayer/animation/audioplayer.h create mode 100644 extensions/include/extensions/multiplayer/animation/catalog.h create mode 100644 extensions/include/extensions/multiplayer/animation/coordinator.h create mode 100644 extensions/include/extensions/multiplayer/animation/loader.h create mode 100644 extensions/include/extensions/multiplayer/animation/locationproximity.h create mode 100644 extensions/include/extensions/multiplayer/animation/phonemeplayer.h create mode 100644 extensions/include/extensions/multiplayer/animation/sceneplayer.h create mode 100644 extensions/include/extensions/multiplayer/animation/sessionhost.h create mode 100644 extensions/include/extensions/multiplayer/emoteanimhandler.h create mode 100644 extensions/include/extensions/multiplayer/mputils.h create mode 100644 extensions/include/extensions/multiplayer/namebubblerenderer.h create mode 100644 extensions/include/extensions/multiplayer/networkmanager.h create mode 100644 extensions/include/extensions/multiplayer/networktransport.h create mode 100644 extensions/include/extensions/multiplayer/platformcallbacks.h create mode 100644 extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h create mode 100644 extensions/include/extensions/multiplayer/platforms/emscripten/websockettransport.h create mode 100644 extensions/include/extensions/multiplayer/platforms/native/lwstransport.h create mode 100644 extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h create mode 100644 extensions/include/extensions/multiplayer/protocol.h create mode 100644 extensions/include/extensions/multiplayer/remoteplayer.h create mode 100644 extensions/include/extensions/multiplayer/sireader.h create mode 100644 extensions/include/extensions/multiplayer/worldstatesync.h create mode 100644 extensions/src/multiplayer.cpp create mode 100644 extensions/src/multiplayer/animation/audioplayer.cpp create mode 100644 extensions/src/multiplayer/animation/catalog.cpp create mode 100644 extensions/src/multiplayer/animation/coordinator.cpp create mode 100644 extensions/src/multiplayer/animation/loader.cpp create mode 100644 extensions/src/multiplayer/animation/locationproximity.cpp create mode 100644 extensions/src/multiplayer/animation/phonemeplayer.cpp create mode 100644 extensions/src/multiplayer/animation/sceneplayer.cpp create mode 100644 extensions/src/multiplayer/animation/sessionhost.cpp create mode 100644 extensions/src/multiplayer/emoteanimhandler.cpp create mode 100644 extensions/src/multiplayer/mputils.cpp create mode 100644 extensions/src/multiplayer/namebubblerenderer.cpp create mode 100644 extensions/src/multiplayer/networkmanager.cpp create mode 100644 extensions/src/multiplayer/platforms/emscripten/callbacks.cpp create mode 100644 extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp create mode 100644 extensions/src/multiplayer/platforms/emscripten/websockettransport.cpp create mode 100644 extensions/src/multiplayer/platforms/native/lwstransport.cpp create mode 100644 extensions/src/multiplayer/platforms/native/nativecallbacks.cpp create mode 100644 extensions/src/multiplayer/protocol.cpp create mode 100644 extensions/src/multiplayer/remoteplayer.cpp create mode 100644 extensions/src/multiplayer/server/.gitignore create mode 100644 extensions/src/multiplayer/server/cors.ts create mode 100644 extensions/src/multiplayer/server/gameroom.ts create mode 100644 extensions/src/multiplayer/server/package-lock.json create mode 100644 extensions/src/multiplayer/server/package.json create mode 100644 extensions/src/multiplayer/server/protocol.ts create mode 100644 extensions/src/multiplayer/server/relay.ts create mode 100644 extensions/src/multiplayer/server/roomregistry.ts create mode 100644 extensions/src/multiplayer/server/wrangler.toml create mode 100644 extensions/src/multiplayer/sireader.cpp create mode 100644 extensions/src/multiplayer/worldstatesync.cpp 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