From 647bd28a07484b1efff9abd2575eacba75860b93 Mon Sep 17 00:00:00 2001 From: foxtacles Date: Sat, 21 Mar 2026 14:40:15 -0700 Subject: [PATCH] Claude/npc animations local playback (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: NPC animation local playback Add NpcAnimCatalog and NpcAnimPlayer for playing NPC interaction animations directly on player ROIs in multiplayer, bypassing the singleplayer streaming pipeline. - NpcAnimCatalog: reads animation entries from LegoAnimationManager with eligibility filtering per actor - NpcAnimPlayer: minimal SI file reader (header + offset table only, then single MxSt read per object), extracts animation/audio/phoneme data from ISLE.SI composite objects - Skeletal animation with position rebasing (absolute world coords converted to player-relative deltas) - Audio via LegoCacheSound with proper WaveFormat parsing from SI chunks, wall-clock sync via SDL_GetTicks - Phoneme lip sync with FLC decode, palette update, and proper texture restore on cleanup - Movement lock via Controller::m_npcAnimPlaying flag - Test trigger: emote 0 plays first eligible NPC animation Still WIP: debug logging present, network sync not implemented, needs testing with more animations. Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Fix NPC animation playback, props, crash safety, and movement lock - Fix SI file reading: use declared offset count, handle RIFF word-alignment for odd-sized MxCh chunks, parse WaveFormat struct directly (not RIFF WAV) - Fix animation type matching: use presenter name instead of MxOb::Type enum (skeletal anim is type Object=0x0B, phoneme is type Video=0x03) - Fix animation positioning: compute full rigid-body rebase transform (savedTransform * inverse(animPose0)) so all motion, rotation, and extra actor positions are preserved relative to the player's current pose - Add extra character support: use CharacterCloner::Clone for root-level characters (RHODA, RD, BD, PG), extend AssignROIIndices to match non-*-prefixed root-level nodes against extra ROIs - Fix phoneme: update palette via SetEntries after FLC decode, restore original texture by passing saved pointer (not NULL), initialize filetype_/volume_ to avoid UBSan errors - Fix sync: use SDL_GetTicks (wall-clock) instead of Timer()->GetTime() (game timer stalls during freezes), defer clock start to first Tick - Fix crash on camera transition: add NPC anim stop callback in Controller::Deactivate and OnWorldDisabled (fires before ROI destruction) - Block camera toggle and scroll/zoom disable during NPC animation - Block player movement and camera-relative input during NPC animation - Add StopNpcAnimation() public API on NetworkManager - Add EnsureROIMapVisibility in Tick for prop visibility Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Fix rebase for nested camera nodes, revert test to eligible[0] Accumulate parent transforms when computing the player's animation- space world pose at time 0. Fixes position offset for animations with nested '-' nodes (e.g. -SBA001BU -> -TILT -> BU) where the local transform alone didn't account for parent TILT offset. Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Catalog filtering, prop LOD trimming, click anim blocking - Split NpcAnimCatalog into NPC (location==-1) and cam (location>=0) buckets - Filter by display actor's character index (not actorId) - Eligibility: require all 5 main actor bits set (no counterpart for now) - DisplayActorToCharacterIndex maps display actor -> g_characters index - Trim trailing digits/underscores from prop LOD names matching original game's e_managedInvisibleRoiTrimmed logic (LETR12 -> letr) - Handle *-prefixed non-actor root siblings as props via CreateAutoROI - Block click animations during NPC animation playback (both local and remote player paths) - Remove verbose per-entry catalog logging Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Actor metadata ROI creation, vehicle reuse, AssignROIIndices fix - Replace scanForCharacters heuristic with LegoAnimActorEntry metadata loop (GetNumActors/GetActorType/GetActorName) matching original game - Player identified by animation name suffix, not tree position - Handle all actor types: managed actors (2), trimmed props (3), exact props (4), scene ROIs (5/6), and scene actors (0/1) - Vehicle ROI reuse: borrow existing ride vehicle ROI for type 0/1 actors when CreateAutoROI fails, restore name on Stop - Fix AssignROIIndices: check extras before claiming root to handle tree ordering (BIKESY before SY) - Use GetRefCount(ROI*) for cleanup instead of name-based Exists() - Block click animations during NPC anim playback - Vehicle animation not yet fully working (known issue) Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Fix vehicle animation, skip Controller tick during NPC anim The ride animation and SyncTransformFromNative in Controller::Tick were overwriting ROI transforms set by NpcAnimPlayer every frame. Skip the entire ride animation path and non-vehicle character animation path when m_npcAnimPlaying is true, so only NpcAnimPlayer controls ROI positioning during NPC animations. Also add comprehensive ROI map and tree assignment debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) * Move SI parsing from NpcAnimPlayer into libweaver Replace ~300 lines of custom RIFF/MxOb/MxCh parsing in NpcAnimPlayer with libweaver's new HeaderOnly read mode and slot-based ReadObject API. This reuses ReadChunk's existing logic for the full MxSt->MxOb->MxCh chain, eliminating code duplication. Extract HD/CD path resolution into a shared ResolveGamePath utility (extensions/common/pathutils), replacing the duplicated pattern in NpcAnimPlayer, SiLoader, and TextureLoader. Update libweaver to 17c7736 (HeaderOnly + ReadObject support). Co-Authored-By: Claude Opus 4.6 (1M context) * Refactor animation playback into Multiplayer::Animation namespace Split the monolithic NpcAnimPlayer and NpcAnimCatalog into five focused components under extensions/multiplayer/animation/: - Catalog: AnimInfo index with category enum, stores all animations - Loader: SI file I/O, parsing, AnimData cache - Controller: Play/Tick/Stop orchestrator, ROI creation, rebase matrix - AudioPlayer: LegoCacheSound timed playback - PhonemePlayer: FLC decoding, texture swap, lip sync Also removes ~50 SDL_Log debug calls, renames all NpcAnim* references to match the new structure, simplifies the camera animation callback API, and documents AnimUtils divergences from the original game. Co-Authored-By: Claude Opus 4.6 (1M context) * DRY: Extract TrimLODSuffix helper and AnimData::ReleaseTracks - controller.cpp: Extract repeated digit/underscore trimming loop into static TrimLODSuffix() (was duplicated 3 times in CreateExtraROIs) - loader.cpp: Extract duplicated track cleanup loops into AnimData::ReleaseTracks() (was in both destructor and move-assignment) Co-Authored-By: Claude Opus 4.6 (1M context) * Updates * Clarify animation system separation with renames and DRY extractions Rename Controller → ScenePlayer to distinguish multi-actor scene animations from simple character poses. Rename AnimData → SceneAnimData to avoid confusion with the character lookup tables. Rename animdata.h/cpp → charactertables.h/cpp to reflect their actual content (walk/idle/emote/vehicle tables). Extract ApplyTree, TrimLODSuffix, and ResolvePropLODName into AnimUtils to reduce duplication across CharacterAnimator and ScenePlayer. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove dta.py accidentally committed Co-Authored-By: Claude Opus 4.6 (1M context) * Fix CleanupProps corrupting global NPC actor state ReleaseActor looks up g_actorInfo[] by ROI name, which for renamed clones (e.g. "ma") matches the real global NPC and deletes its actor entity. Since all props are independent clones (not obtained via GetActorROI), use ReleaseAutoROI unconditionally — it performs identical map/ROI cleanup without touching g_actorInfo[]. Also removes the redundant explicit Remove() call since ReleaseAutoROI already calls RemoveROI() internally. Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Add animation catalog rework, location proximity, and coordinator Prerequisite systems for cooperative animation reenactment feature: - Catalog: expanded CatalogEntry with performerMask, spectatorMask, location index. Exact character matching (replaces engine's lossy 2-char prefix). New CanTrigger() checks collective player eligibility (spectator + all performers, mutually exclusive roles). - LocationProximity: 2D XZ distance to nearest g_locations entry, integrated into Tickle with OnNearestLocationChanged callback. Remote player locations derived from ROI positions. - Coordinator: state machine (idle/interested/countdown/playing/completed), ComputeEligibility for frontend consumption (pre-filtered to local player's participable animations). Networking hooks are stubs. - NetworkManager: location proximity + coordinator integration, atomic request pattern for anim interest/cancel, state resets on all transition paths (world disable, disconnect, reconnect, stop). - Bridge: OnNearestLocationChanged callback (Emscripten + Native), mp_set_anim_interest / mp_cancel_anim_interest WASM exports. Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Add push-based animation state bridge and slot fill computation Add OnAnimationsAvailable callback that pushes full animation eligibility state (location, coordinator state, per-animation slot fill status) to the frontend whenever relevant state changes. Uses a dirty flag system with 250ms cooldown (bypassed for interest changes) to batch updates. - Add CanTriggerDetailed to Catalog (refactor CanTrigger as wrapper) - Enrich EligibilityInfo with SlotInfo vector and CatalogEntry pointer - Add Coordinator::OnLocationChanged for auto-clearing stale interest - Add dirty flag triggers at all 7 state change points in NetworkManager - Build JSON payload with unified slots (performers + spectator) - Remove OnNearestLocationChanged (superseded by push-based approach) Co-Authored-By: Claude Opus 4.6 (1M context) * Ghastly * WIP * Fix animation session sync, camera-cancel, and state management bugs - Add m_cancelPending to coordinator to prevent stale session re-enrollment - Allow ClearInterest during countdown/playing for camera-cancel support - Camera toggle now cancels animation in any active state instead of blocking - Safety net: cancel animation when camera is disabled by any source - Push idle JSON when camera unavailable so frontend clears countdown UI - HandleCancel includes playing sessions and erases them (explicit cancel stops all) - HandleAnimCancel/HandleAnimUpdate detect playing→idle to stop local scenes - SendAnimUpdateToPlayer for targeted session sync to newly joined players - HandleHostAssign: skip ResetAnimationState on initial assignment to avoid race - IsPeerNearby helper for shared proximity checks - HandleAnimInterest: evict far-away participants when session is full - PushAnimationState: suppress session display when no participant is nearby - World filter in UpdateRemotePlayers and PushAnimationState - Set m_animStateDirty on camera change and remote player world change - Use anyInIsle instead of anyNearby for continuous proximity-based push Co-Authored-By: Claude Opus 4.6 (1M context) * Clean up animation code: extract DRY helpers, remove debug logging - Extract BuildAnimUpdateMsg() and ExtractSlotPeerIds() to deduplicate message-building logic in BroadcastAnimUpdate/SendAnimUpdateToPlayer - Replace manual tree iteration in controller.cpp with AnimUtils::ApplyTree - Remove all [Anim]/[SessionHost] SDL_Log debug calls and unused includes Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: ScenePlayer multi-participant support and cam_anim playback Co-Authored-By: Claude Opus 4.6 (1M context) * WIP: Vehicle ROI support, alias-based ROI mapping, audio fix Co-Authored-By: Claude Opus 4.6 (1M context) * Refactor animation infrastructure: remove dead code, DRY, tighten data Remove all debug logging (~20 SDL_Log calls), dead fields (m_savedVehicleName, m_debugFirstTickLogged, boundingRadius, centerPoint), dead methods (RestoreVehicleROI, HasActiveSounds), and unused parameters (Tick's deltaTime). Tighten data structures: replace raw m_propROIs array with vector, derive isSpectator from charIndex via IsSpectator() method (SessionSlot, ParticipantROI), group action transform fields into sub-struct, replace vehicle category magic numbers with VehicleCategory enum. Extract DRY helpers: addAlias/createProp lambdas in SetupROIs, StopScenePlayback() in NetworkManager (5 call sites), combine dual slot iteration into single loop in HandleAnimStartLocally. Simplify Play() signature by removing redundant p_localROI/p_vehicleROI params. Fix bug: HandleAnimCancel now unlocks remote player ROIs when stopping during playback (was skipping unlock, leaving remotes permanently locked). Co-Authored-By: Claude Opus 4.6 (1M context) * Async SI asset preloading during animation countdown Preload animation data from isle.si on a background thread when the countdown starts (4s window), so ScenePlayer::Play() finds the data already cached and avoids a 500ms-1s main-thread stall. Adds Loader::PreloadAsync() with a one-shot MxThread subclass that opens its own si::File/Interleaf, parses the object, and inserts into the cache under MxCriticalSection. EnsureCached() joins any in-progress preload before falling back to synchronous load. Co-Authored-By: Claude Opus 4.6 (1M context) * Refactor animation system: fix dangling pointer, DRY extractions, correctness - Fix dangling .c_str() in ScenePlayer::SetupROIs by using std::deque for aliasNames (vector reallocation invalidated stored pointers) - Push idle JSON fallback when userActor is null in PushAnimationState instead of silently returning with stale frontend state - Extract GetPerformerIndices() to eliminate 3 duplicate bit-iteration loops across coordinator.cpp and sessionhost.cpp - Promote CheckSpectatorMask to public Catalog method, replacing inlined duplicate in sessionhost.cpp - Extract Loader::OpenSIHeaderOnly() to consolidate duplicated SI file open/parse between OpenSI() and PreloadThread::Run() - Extract IDLE_ANIM_STATE_JSON constant to avoid string duplication - Clarify proximity radius distinction with comment Co-Authored-By: Claude Opus 4.6 (1M context) * Observer mode: uninvolved players see animations play out locally Non-participant players in the same world now see scene animations (cam_anim, npc_anim) play out on the performing players' ROIs as an ambient background scene. The observer's camera, movement, and vehicle are completely unaffected. Key changes: - HandleAnimStartLocally always runs (not just for session participants) - ScenePlayer gains observer mode: skips camera control, spectator hiding, and vehicle hiding - Preload during countdown for all clients, not just participants - Fix 1st person camera: skip display ROI check for observers (the display clone is destroyed in 1st person mode) - Track m_playingAnimIndex for observer early-stop detection Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- 3rdparty/CMakeLists.txt | 4 +- CMakeLists.txt | 11 +- .../legoomni/include/legoanimationmanager.h | 1 + .../include/extensions/common/animutils.h | 22 +- .../extensions/common/characteranimator.h | 2 +- .../common/{animdata.h => charactertables.h} | 2 +- .../include/extensions/common/pathutils.h | 17 + extensions/include/extensions/fwd.h | 5 + .../multiplayer/animation/audioplayer.h | 33 + .../multiplayer/animation/catalog.h | 84 ++ .../multiplayer/animation/coordinator.h | 105 ++ .../extensions/multiplayer/animation/loader.h | 114 ++ .../multiplayer/animation/locationproximity.h | 33 + .../multiplayer/animation/phonemeplayer.h | 33 + .../multiplayer/animation/sceneplayer.h | 97 ++ .../multiplayer/animation/sessionhost.h | 77 ++ .../extensions/multiplayer/networkmanager.h | 59 +- .../multiplayer/platformcallbacks.h | 6 + .../platforms/emscripten/callbacks.h | 1 + .../platforms/native/nativecallbacks.h | 1 + .../include/extensions/multiplayer/protocol.h | 37 + .../extensions/multiplayer/remoteplayer.h | 8 + .../extensions/thirdpersoncamera/controller.h | 21 + extensions/src/common/animutils.cpp | 141 ++- extensions/src/common/characteranimator.cpp | 65 +- .../{animdata.cpp => charactertables.cpp} | 2 +- extensions/src/common/pathutils.cpp | 24 + .../src/multiplayer/animation/audioplayer.cpp | 50 + .../src/multiplayer/animation/catalog.cpp | 228 ++++ .../src/multiplayer/animation/coordinator.cpp | 271 +++++ .../src/multiplayer/animation/loader.cpp | 442 +++++++ .../animation/locationproximity.cpp | 60 + .../multiplayer/animation/phonemeplayer.cpp | 169 +++ .../src/multiplayer/animation/sceneplayer.cpp | 575 +++++++++ .../src/multiplayer/animation/sessionhost.cpp | 310 +++++ extensions/src/multiplayer/networkmanager.cpp | 1058 ++++++++++++++++- .../platforms/emscripten/callbacks.cpp | 14 + .../platforms/emscripten/wasm_exports.cpp | 16 + .../platforms/native/nativecallbacks.cpp | 5 + extensions/src/multiplayer/remoteplayer.cpp | 13 +- extensions/src/siloader.cpp | 15 +- extensions/src/textureloader.cpp | 14 +- extensions/src/thirdpersoncamera.cpp | 2 +- .../src/thirdpersoncamera/controller.cpp | 53 +- 44 files changed, 4180 insertions(+), 120 deletions(-) rename extensions/include/extensions/common/{animdata.h => charactertables.h} (95%) create mode 100644 extensions/include/extensions/common/pathutils.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 rename extensions/src/common/{animdata.cpp => charactertables.cpp} (98%) create mode 100644 extensions/src/common/pathutils.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 diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index c18fee26..cb3918b7 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -55,8 +55,8 @@ if(DOWNLOAD_DEPENDENCIES) include(FetchContent) FetchContent_Populate( libweaver - URL https://github.com/isledecomp/SIEdit/archive/afd4933844b95ef739a7e77b097deb7efe4ec576.tar.gz - URL_MD5 59fd3c36f4f380f730cd9bedfc846397 + URL https://github.com/isledecomp/SIEdit/archive/17c7736a6ff31413f1e74ab4e989011b545b6926.tar.gz + URL_MD5 04edbc974df8884f283d920ded10f1f6 ) add_library(libweaver STATIC ${libweaver_SOURCE_DIR}/lib/core.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dd2b5a30..3f033c76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -534,12 +534,13 @@ if (ISLE_EXTENSIONS) extensions/src/textureloader.cpp # Common shared code - extensions/src/common/animdata.cpp + extensions/src/common/charactertables.cpp extensions/src/common/animutils.cpp extensions/src/common/characteranimator.cpp extensions/src/common/charactercloner.cpp extensions/src/common/charactercustomizer.cpp extensions/src/common/customizestate.cpp + extensions/src/common/pathutils.cpp # Third person camera extension extensions/src/thirdpersoncamera.cpp @@ -549,6 +550,14 @@ if (ISLE_EXTENSIONS) extensions/src/thirdpersoncamera/displayactor.cpp # Multiplayer extension + 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/sceneplayer.cpp + extensions/src/multiplayer/animation/sessionhost.cpp + extensions/src/multiplayer/animation/audioplayer.cpp + extensions/src/multiplayer/animation/phonemeplayer.cpp extensions/src/multiplayer.cpp extensions/src/multiplayer/namebubblerenderer.cpp extensions/src/multiplayer/networkmanager.cpp diff --git a/LEGO1/lego/legoomni/include/legoanimationmanager.h b/LEGO1/lego/legoomni/include/legoanimationmanager.h index 4990ed60..cb1c6e1b 100644 --- a/LEGO1/lego/legoomni/include/legoanimationmanager.h +++ b/LEGO1/lego/legoomni/include/legoanimationmanager.h @@ -205,6 +205,7 @@ class LegoAnimationManager : public MxCore { private: friend class Multiplayer::NetworkManager; + friend class Multiplayer::Animation::Catalog; void Init(); MxResult FUN_100605e0( diff --git a/extensions/include/extensions/common/animutils.h b/extensions/include/extensions/common/animutils.h index 7685084c..401d24a3 100644 --- a/extensions/include/extensions/common/animutils.h +++ b/extensions/include/extensions/common/animutils.h @@ -58,13 +58,23 @@ struct AnimCache { } }; +// Maps an animation character name to an ROI without renaming the ROI. +// Used for participant ROIs whose real names (e.g. "tp_display") differ +// from the animation tree node names (e.g. "pepper"). +struct ROIAlias { + const char* animName; // name in animation tree (lowercased) + LegoROI* roi; // actual ROI to use +}; + void BuildROIMap( LegoAnim* p_anim, LegoROI* p_rootROI, LegoROI** p_extraROIs, int p_extraROICount, LegoROI**& p_roiMap, - MxU32& p_roiMapSize + MxU32& p_roiMapSize, + const ROIAlias* p_aliases = nullptr, + int p_aliasCount = 0 ); void CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector& p_unmatchedNames); @@ -80,6 +90,16 @@ inline void EnsureROIMapVisibility(LegoROI** p_roiMap, MxU32 p_roiMapSize) } } +// Apply animation transformation to all root children of an animation tree. +void ApplyTree(LegoAnim* p_anim, MxMatrix& p_transform, LegoTime p_time, LegoROI** p_roiMap); + +// Strip trailing digits and underscores from a name to get the LOD base name. +// Mirrors the digit-trimming in LegoAnimPresenter::CreateManagedActors/CreateSceneROIs. +std::string TrimLODSuffix(const std::string& p_name); + +// Maps animation tree node names to actual LOD names when they differ. +const char* ResolvePropLODName(const char* p_nodeName); + // Flip a matrix from forward-z to backward-z (or vice versa) in place. inline void FlipMatrixDirection(MxMatrix& p_mat) { diff --git a/extensions/include/extensions/common/characteranimator.h b/extensions/include/extensions/common/characteranimator.h index 9419b533..604bf95b 100644 --- a/extensions/include/extensions/common/characteranimator.h +++ b/extensions/include/extensions/common/characteranimator.h @@ -1,7 +1,7 @@ #pragma once -#include "extensions/common/animdata.h" #include "extensions/common/animutils.h" +#include "extensions/common/charactertables.h" #include "mxgeometry/mxmatrix.h" #include "mxtypes.h" diff --git a/extensions/include/extensions/common/animdata.h b/extensions/include/extensions/common/charactertables.h similarity index 95% rename from extensions/include/extensions/common/animdata.h rename to extensions/include/extensions/common/charactertables.h index 803640d2..9f7ab696 100644 --- a/extensions/include/extensions/common/animdata.h +++ b/extensions/include/extensions/common/charactertables.h @@ -11,7 +11,7 @@ namespace Extensions namespace Common { -// Animation and vehicle tables (defined in animdata.cpp) +// Animation and vehicle tables (defined in charactertables.cpp) extern const char* const g_walkAnimNames[]; extern const int g_walkAnimCount; diff --git a/extensions/include/extensions/common/pathutils.h b/extensions/include/extensions/common/pathutils.h new file mode 100644 index 00000000..ba3e7eec --- /dev/null +++ b/extensions/include/extensions/common/pathutils.h @@ -0,0 +1,17 @@ +#pragma once + +#include "mxstring.h" + +namespace Extensions +{ +namespace Common +{ + +// Resolve a relative game path (e.g. "\\lego\\scripts\\isle\\isle.si") +// by trying the HD path first, then falling back to CD. +// Returns true if the file exists at either location, with the +// filesystem-mapped result in p_outPath. +bool ResolveGamePath(const char* p_relativePath, MxString& p_outPath); + +} // namespace Common +} // namespace Extensions diff --git a/extensions/include/extensions/fwd.h b/extensions/include/extensions/fwd.h index aed06b0a..c10c809d 100644 --- a/extensions/include/extensions/fwd.h +++ b/extensions/include/extensions/fwd.h @@ -20,6 +20,11 @@ namespace Multiplayer { class NetworkManager; class WorldStateSync; +namespace Animation +{ +class Catalog; +class Controller; +} // namespace Animation } // namespace Multiplayer #endif // EXTENSIONS_FWD_H 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..11f536b2 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/catalog.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +class LegoAnimationManager; +struct AnimInfo; + +namespace Multiplayer::Animation +{ + +enum AnimCategory : uint8_t { + e_npcAnim, // characterIndex >= 0 && location == -1 + e_camAnim, // characterIndex >= 0 && location >= 0 + e_otherAnim // characterIndex < 0 (ambient, non-character) +}; + +// Number of core playable characters (Pepper, Mama, Papa, Nick, Laura) = g_characters 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; + +// Extract the character indices from a performer bitmask. +std::vector GetPerformerIndices(uint64_t p_performerMask); + +struct CatalogEntry { + uint16_t animIndex; // Index into LegoAnimationManager::m_anims[] + AnimCategory category; + uint8_t spectatorMask; // Which core actors can trigger (bit0=Pepper..bit4=Laura) + uint64_t performerMask; // Bitmask of g_characters[] indices that appear as character models + int16_t location; // -1 = anywhere, >= 0 = specific location + int8_t characterIndex; // Primary character index into g_characters[] + uint8_t modelCount; // Number of models in animation +}; + +class Catalog { +public: + void Refresh(LegoAnimationManager* p_am); + + 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_characters index internally). + bool CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActorIndex) const; + + // Same check but using a g_characters 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_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, + 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); + + // Convert a display actor index to the g_characters[] index used by animations. + // Returns -1 if no match. + static int8_t DisplayActorToCharacterIndex(uint8_t p_displayActorIndex); + +private: + std::vector m_entries; + std::map> m_locationIndex; // location ID → indices into m_entries + AnimInfo* m_animsBase; + uint16_t m_animCount; +}; + +} // 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..ffd1b5c5 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/coordinator.h @@ -0,0 +1,105 @@ +#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_peerId); + void SetInterest(uint16_t p_animIndex); + 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). + std::vector ComputeEligibility( + int16_t p_location, + const int8_t* p_locationChars, + uint8_t p_locationCount, + const int8_t* p_proximityChars, + uint8_t p_proximityCount + ) const; + + // Auto-clear interest if current animation is not available at the new location. + void OnLocationChanged(int16_t p_location, const Catalog* p_catalog); + + void Reset(); + + // Apply authoritative session state from host + void ApplySessionUpdate( + uint16_t p_animIndex, + 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_animIndex); + + // 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..df566aab --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/loader.h @@ -0,0 +1,114 @@ +#pragma once + +#include "mxcriticalsection.h" +#include "mxthread.h" +#include "mxwavepresenter.h" + +#include +#include +#include +#include +#include + +struct FLIC_HEADER; +class LegoAnim; + +namespace si +{ +class File; +class Interleaf; +class Object; +} // namespace si + +namespace Multiplayer::Animation +{ + +struct SceneAnimData { + LegoAnim* anim; + float duration; + + struct AudioTrack { + MxU8* pcmData; + MxU32 pcmDataSize; + MxWavePresenter::WaveFormat format; + std::string mediaSrcPath; + int32_t volume; + uint32_t timeOffset; + }; + 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 ISLE.SI on demand, bypassing the streaming pipeline. +// Reads only the RIFF header + offset table on first open, then seeks to +// individual objects as requested. +class Loader { +public: + Loader(); + ~Loader(); + + bool OpenSI(); + SceneAnimData* EnsureCached(uint32_t p_objectId); + void PreloadAsync(uint32_t p_objectId); + +private: + class PreloadThread : public MxThread { + public: + PreloadThread(Loader* p_loader, uint32_t p_objectId); + MxResult Run() override; + + private: + Loader* m_loader; + uint32_t m_objectId; + }; + + static bool OpenSIHeaderOnly(const char* p_siPath, si::File*& p_file, si::Interleaf*& p_interleaf); + bool ReadObject(uint32_t p_objectId); + static bool ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data); + static bool ParseSoundChild(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(); + + si::File* m_siFile; + si::Interleaf* m_interleaf; + bool m_siReady; + std::map m_cache; + MxCriticalSection m_cacheCS; + + PreloadThread* m_preloadThread; + 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..32b1a357 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/locationproximity.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace Multiplayer::Animation +{ + +static constexpr float NPC_ANIM_PROXIMITY = 15.0f; + +class LocationProximity { +public: + LocationProximity(); + + // Returns true if nearest location changed since last call + bool Update(float p_x, float p_z); + + int16_t GetNearestLocation() const { return m_nearestLocation; } + float GetNearestDistance() const { return m_nearestDistance; } + + void SetRadius(float p_radius) { m_radius = p_radius; } + float GetRadius() const { return m_radius; } + void Reset(); + + // Static version for computing any position's nearest location + static int16_t ComputeNearest(float p_x, float p_z, float p_radius); + +private: + int16_t m_nearestLocation; + float m_nearestDistance; + float m_radius; +}; + +} // 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..b8d2cb4a --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/phonemeplayer.h @@ -0,0 +1,33 @@ +#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); + void Tick(float p_elapsedMs, const std::vector& p_tracks); + void Cleanup(); + +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..07cd3e87 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sceneplayer.h @@ -0,0 +1,97 @@ +#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_characters[] 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, + AnimCategory p_category, + const ParticipantROI* p_participants, + uint8_t p_participantCount, + bool p_observerMode = false + ); + void Tick(); + void Stop(); + bool IsPlaying() const { return m_playing; } + + void PreloadAsync(uint32_t p_objectId) { m_loader.PreloadAsync(p_objectId); } + +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; + + // Props created for the animation (cloned characters and prop models) + std::vector m_propROIs; + + 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..228179b2 --- /dev/null +++ b/extensions/include/extensions/multiplayer/animation/sessionhost.h @@ -0,0 +1,77 @@ +#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_characters index, or -1 for spectator + + bool IsSpectator() const { return 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 animIndex of session ready to play, or ANIM_INDEX_NONE + uint16_t Tick(uint32_t p_now); + + void StartCountdown(uint16_t p_animIndex); + void RevertCountdown(uint16_t p_animIndex); + + void Reset(); + void EraseSession(uint16_t p_animIndex); + + const AnimSession* FindSession(uint16_t p_animIndex) const; + const std::map& 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/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 9e533c60..3d3ad76f 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -1,5 +1,10 @@ #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" @@ -75,6 +80,11 @@ class NetworkManager : public MxCore { 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; } bool GetShowNameBubbles() const { return m_showNameBubbles; } @@ -83,6 +93,10 @@ class NetworkManager : public MxCore { 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(); @@ -116,6 +130,26 @@ class NetworkManager : public MxCore { void HandleEmote(const EmoteMsg& p_msg); void HandleCustomize(const CustomizeMsg& p_msg); + // Animation coordination handlers + void HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex); + void HandleAnimCancel(uint32_t p_peerId); + void HandleAnimUpdate(const AnimUpdateMsg& p_msg); + void HandleAnimStart(const AnimStartMsg& p_msg); + void HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession); + AnimUpdateMsg BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target); + void BroadcastAnimUpdate(uint16_t p_animIndex); + void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId); + void BroadcastAnimStart(uint16_t p_animIndex); + int16_t GetPeerLocation(uint32_t p_peerId) 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; + bool ValidateSessionLocations(uint16_t p_animIndex); + + void ResetAnimationState(); + void CancelLocalAnimInterest(); + void BroadcastChangedSessions(const std::vector& p_changedAnims); + void TickHostSessions(); + void ProcessPendingRequests(); void RemoveRemotePlayer(uint32_t p_peerId); void RemoveAllRemotePlayers(); @@ -126,6 +160,7 @@ class NetworkManager : public MxCore { void NotifyPlayerCountChanged(); void EnforceDisableNPCs(); + void PushAnimationState(); // Serialize and send a fixed-size message via the transport template @@ -153,12 +188,31 @@ class NetworkManager : public MxCore { std::atomic m_pendingIdleAnim; std::atomic m_pendingEmote; std::atomic m_pendingToggleAllowCustomize; + std::atomic m_pendingAnimInterest; + std::atomic m_pendingAnimCancel; bool m_disableAllNPCs; bool m_showNameBubbles; bool m_lastCameraEnabled; bool m_wasInRestrictedArea; + // NPC animation playback + Multiplayer::Animation::Catalog m_animCatalog; + Multiplayer::Animation::ScenePlayer m_scenePlayer; + Multiplayer::Animation::LocationProximity m_locationProximity; + Multiplayer::Animation::Coordinator m_animCoordinator; + Multiplayer::Animation::SessionHost m_animSessionHost; + int32_t m_localPendingAnimInterest; + uint16_t m_playingAnimIndex; + + void TickAnimation(); + void StopScenePlayback(bool p_unlockRemotes); + + // Animation state push + bool m_animStateDirty; + bool m_animInterestDirty; + uint32_t m_lastAnimPushTime; + ConnectionState m_connectionState; bool m_wasRejected; std::string m_roomId; @@ -166,11 +220,12 @@ class NetworkManager : public MxCore { 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 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 }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h index 7004e948..da14359e 100644 --- a/extensions/include/extensions/multiplayer/platformcallbacks.h +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace Multiplayer { @@ -27,6 +29,10 @@ class PlatformCallbacks { // 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; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h index 34e4ac19..4e95ecd0 100644 --- a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -14,6 +14,7 @@ class EmscriptenCallbacks : public PlatformCallbacks { 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; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h index fd5428a0..7049ac02 100644 --- a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h @@ -14,6 +14,7 @@ class NativeCallbacks : public PlatformCallbacks { 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; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 056e40f2..737057a5 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -25,6 +25,10 @@ enum MessageType : uint8_t { 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_ASSIGN_ID = 0xFF }; @@ -150,6 +154,39 @@ struct CustomizeMsg { 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; +}; + #pragma pack(pop) using Extensions::Common::IsValidActorId; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index d201a896..4c0fe269 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -36,6 +36,8 @@ class RemotePlayer { bool IsSpawned() const { return m_spawned; } bool IsVisible() const { return m_visible; } int8_t GetWorldId() const { return m_targetWorldId; } + int16_t GetNearestLocation() const { return m_nearestLocation; } + void SetNearestLocation(int16_t p_location) { m_nearestLocation = p_location; } uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } void SetVisible(bool p_visible); void TriggerEmote(uint8_t p_emoteId); @@ -48,9 +50,13 @@ class RemotePlayer { 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 IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); } + void SetAnimationLocked(bool p_locked) { m_animationLocked = p_locked; } + bool IsAnimationLocked() const { return m_animationLocked; } + private: const char* GetDisplayActorName() const; void UpdateTransform(float p_deltaTime); @@ -76,6 +82,7 @@ class RemotePlayer { int8_t m_targetWorldId; uint32_t m_lastUpdateTime; bool m_hasReceivedUpdate; + int16_t m_nearestLocation; float m_currentPosition[3]; float m_currentDirection[3]; @@ -89,6 +96,7 @@ class RemotePlayer { Extensions::Common::CustomizeState m_customizeState; bool m_allowRemoteCustomize; + bool m_animationLocked; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/thirdpersoncamera/controller.h b/extensions/include/extensions/thirdpersoncamera/controller.h index 602f2655..f04a3190 100644 --- a/extensions/include/extensions/thirdpersoncamera/controller.h +++ b/extensions/include/extensions/thirdpersoncamera/controller.h @@ -8,6 +8,7 @@ #include #include +#include class IslePathActor; class LegoNavController; @@ -56,6 +57,23 @@ class Controller { 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(); } + + // Signal that an external animation is active. + // p_lockDisplay: true if the display ROI is being driven by the animation (performer), + // false if the local player is just spectating (idle anim continues). + // p_onStop is called before the display ROI is destroyed (Deactivate/OnWorldDisabled). + void SetAnimPlaying( + bool p_animPlaying, + bool p_lockDisplay = true, + std::function p_animStopCallback = nullptr + ) + { + m_animPlaying = p_animPlaying; + m_animLockDisplay = p_animPlaying && p_lockDisplay; + m_animStopCallback = p_animPlaying ? std::move(p_animStopCallback) : nullptr; + } + bool IsAnimPlaying() const { return m_animPlaying; } void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -119,6 +137,9 @@ class Controller { bool m_enabled; bool m_active; bool m_pendingWorldTransition; + bool m_animPlaying; + bool m_animLockDisplay; + std::function m_animStopCallback; bool m_lmbForwardEngaged; LegoROI* m_playerROI; }; diff --git a/extensions/src/common/animutils.cpp b/extensions/src/common/animutils.cpp index fc2319ff..cf95c46a 100644 --- a/extensions/src/common/animutils.cpp +++ b/extensions/src/common/animutils.cpp @@ -7,6 +7,7 @@ #include "misc/legotree.h" #include "roi/legoroi.h" +#include #include #include @@ -14,12 +15,33 @@ using namespace Extensions::Common; // Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime // via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes. +// +// Intentional divergences from LegoAnimPresenter::BuildROIMap (legoanimpresenter.cpp:413-530): +// 1. No variable substitution -- we bypass the streaming pipeline, so the variable +// table lacks our entries. Direct name comparison instead. +// 2. *-prefixed nodes search extraROIs -- the original's GetActorName() depends on +// presenter action context (m_action->GetUnknown24()). We search created extra +// ROIs directly. +// 3. No LegoAnimStructMap dedup -- sequential indices, functionally correct. +// Look up an animation node name in the alias map (case-insensitive). +static LegoROI* FindAlias(const char* p_name, const AnimUtils::ROIAlias* p_aliases, int p_aliasCount) +{ + for (int i = 0; i < p_aliasCount; i++) { + if (p_aliases[i].animName && !SDL_strcasecmp(p_name, p_aliases[i].animName)) { + return p_aliases[i].roi; + } + } + return nullptr; +} + static void AssignROIIndices( LegoTreeNode* p_node, LegoROI* p_parentROI, LegoROI* p_rootROI, LegoROI** p_extraROIs, int p_extraROICount, + const AnimUtils::ROIAlias* p_aliases, + int p_aliasCount, MxU32& p_nextIndex, std::vector& p_entries, bool& p_rootClaimed @@ -34,27 +56,50 @@ static void AssignROIIndices( if (*name == '*' || p_parentROI == nullptr) { roi = p_rootROI; - if (!p_rootClaimed) { - matchedROI = p_rootROI; + + const char* searchName = (*name == '*') ? name + 1 : name; + bool matchedExtra = false; + + // Check aliases first (participant ROIs mapped by character name). + // Claiming root prevents subsequent sibling nodes from also claiming it. + matchedROI = FindAlias(searchName, p_aliases, p_aliasCount); + if (matchedROI) { + roi = matchedROI; + matchedExtra = true; p_rootClaimed = true; } - else if (*name == '*' && p_extraROICount > 0) { - // Subsequent *-prefixed node: search extra ROIs by stripped name. - // FindChildROI checks self first, then children recursively. - const char* stripped = name + 1; + + // Then check extra ROIs by name. + // This handles cases like BIKESY appearing before SY in the tree: + // BIKESY should match the vehicle extra, not claim the root. + if (!matchedExtra && p_extraROICount > 0) { for (int e = 0; e < p_extraROICount; e++) { - matchedROI = p_extraROIs[e]->FindChildROI(stripped, p_extraROIs[e]); + matchedROI = p_extraROIs[e]->FindChildROI(searchName, p_extraROIs[e]); if (matchedROI != nullptr) { + roi = matchedROI; + matchedExtra = true; break; } } } + + if (!matchedExtra) { + if (!p_rootClaimed) { + matchedROI = p_rootROI; + p_rootClaimed = true; + } + } } else { matchedROI = p_parentROI->FindChildROI(name, p_parentROI); if (matchedROI == nullptr) { - // FindChildROI checks self first, so this handles both - // direct name matches and child searches on extra ROIs. + // Check aliases — also update roi so children resolve against the alias ROI + matchedROI = FindAlias(name, p_aliases, p_aliasCount); + if (matchedROI) { + roi = matchedROI; + } + } + if (matchedROI == nullptr) { for (int e = 0; e < p_extraROICount; e++) { matchedROI = p_extraROIs[e]->FindChildROI(name, p_extraROIs[e]); if (matchedROI != nullptr) { @@ -62,6 +107,36 @@ static void AssignROIIndices( } } } + // Mirrors original game (legoanimpresenter.cpp:486-490): + // If FindChildROI fails, the node might be a top-level actor that isn't + // a child of the current parent. Re-run this node with p_parentROI=NULL + // so it enters the root-claiming / top-level search path instead. + if (matchedROI == nullptr) { + bool isTopLevel = false; + // Check aliases for top-level match + if (FindAlias(name, p_aliases, p_aliasCount) != nullptr) { + isTopLevel = true; + } + if (!isTopLevel && !p_rootClaimed && p_rootROI->GetName() && + !SDL_strcasecmp(name, p_rootROI->GetName())) { + isTopLevel = true; + } + if (!isTopLevel) { + for (int e = 0; e < p_extraROICount; e++) { + if (p_extraROIs[e]->GetName() && !SDL_strcasecmp(name, p_extraROIs[e]->GetName())) { + isTopLevel = true; + break; + } + } + } + if (isTopLevel) { + AssignROIIndices( + p_node, nullptr, p_rootROI, p_extraROIs, p_extraROICount, + p_aliases, p_aliasCount, p_nextIndex, p_entries, p_rootClaimed + ); + return; + } + } } if (matchedROI != nullptr) { @@ -81,6 +156,8 @@ static void AssignROIIndices( p_rootROI, p_extraROIs, p_extraROICount, + p_aliases, + p_aliasCount, p_nextIndex, p_entries, p_rootClaimed @@ -94,7 +171,9 @@ void AnimUtils::BuildROIMap( LegoROI** p_extraROIs, int p_extraROICount, LegoROI**& p_roiMap, - MxU32& p_roiMapSize + MxU32& p_roiMapSize, + const ROIAlias* p_aliases, + int p_aliasCount ) { if (!p_anim || !p_rootROI) { @@ -109,7 +188,7 @@ void AnimUtils::BuildROIMap( MxU32 nextIndex = 1; std::vector entries; bool rootClaimed = false; - AssignROIIndices(root, nullptr, p_rootROI, p_extraROIs, p_extraROICount, nextIndex, entries, rootClaimed); + AssignROIIndices(root, nullptr, p_rootROI, p_extraROIs, p_extraROICount, p_aliases, p_aliasCount, nextIndex, entries, rootClaimed); if (entries.empty()) { return; @@ -223,3 +302,43 @@ void AnimUtils::CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std: bool rootClaimed = false; CollectUnmatchedNodesRecursive(root, nullptr, p_rootROI, p_unmatchedNames, rootClaimed); } + +void AnimUtils::ApplyTree(LegoAnim* p_anim, MxMatrix& p_transform, LegoTime p_time, LegoROI** p_roiMap) +{ + LegoTreeNode* root = p_anim->GetRoot(); + for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { + LegoROI::ApplyAnimationTransformation(root->GetChild(i), p_transform, p_time, p_roiMap); + } +} + +std::string AnimUtils::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; +} + +const char* AnimUtils::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; +} diff --git a/extensions/src/common/characteranimator.cpp b/extensions/src/common/characteranimator.cpp index 35da0c2d..c0c0b4b0 100644 --- a/extensions/src/common/characteranimator.cpp +++ b/extensions/src/common/characteranimator.cpp @@ -96,10 +96,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) float timeInCycle = m_animTime - duration * SDL_floorf(m_animTime / duration); MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = walkAnim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, walkRoiMap); - } + AnimUtils::ApplyTree(walkAnim, transform, (LegoTime) timeInCycle, walkRoiMap); } m_wasMoving = true; m_idleTime = 0.0f; @@ -139,15 +136,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) m_emotePropGroup.roiMap != nullptr ? m_emotePropGroup.roiMap : m_emoteAnimCache->roiMap; MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World()); - LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) m_emoteTime, - emoteRoiMap - ); - } + AnimUtils::ApplyTree(m_emoteAnimCache->anim, transform, (LegoTime) m_emoteTime, emoteRoiMap); // Restore player ROI transform (animation root overwrote it). if (m_config.saveEmoteTransform) { @@ -159,15 +148,12 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) // Frozen at last frame of a multi-part emote's phase-1 animation MxMatrix transform(m_config.saveEmoteTransform ? m_frozenParentTransform : p_roi->GetLocal2World()); - LegoTreeNode* root = m_frozenAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) m_frozenAnimDuration, - m_frozenAnimCache->roiMap - ); - } + AnimUtils::ApplyTree( + m_frozenAnimCache->anim, + transform, + (LegoTime) m_frozenAnimDuration, + m_frozenAnimCache->roiMap + ); if (m_config.saveEmoteTransform) { p_roi->WrappedSetLocal2WorldWithWorldDataUpdate(m_frozenParentTransform); @@ -193,15 +179,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving) float timeInCycle = m_idleAnimTime - duration * SDL_floorf(m_idleAnimTime / duration); MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = m_idleAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) timeInCycle, - m_idleAnimCache->roiMap - ); - } + AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) timeInCycle, m_idleAnimCache->roiMap); } } } @@ -448,24 +426,6 @@ void CharacterAnimator::ClearPropGroup(PropGroup& p_group) p_group.anim = nullptr; } -// Maps animation tree node names to actual LOD names when they differ. -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 CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, LegoROI* p_playerROI) { std::vector unmatchedNames; @@ -484,7 +444,7 @@ void CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, Le SDL_snprintf(uniqueName, sizeof(uniqueName), "tp_prop_%s", name.c_str()); } - const char* lodName = ResolvePropLODName(name.c_str()); + const char* lodName = AnimUtils::ResolvePropLODName(name.c_str()); LegoROI* propROI = CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE); if (propROI) { propROI->SetName(name.c_str()); @@ -545,8 +505,5 @@ void CharacterAnimator::ApplyIdleFrame0(LegoROI* p_roi) } MxMatrix transform(p_roi->GetLocal2World()); - LegoTreeNode* root = m_idleAnimCache->anim->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap); - } + AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap); } diff --git a/extensions/src/common/animdata.cpp b/extensions/src/common/charactertables.cpp similarity index 98% rename from extensions/src/common/animdata.cpp rename to extensions/src/common/charactertables.cpp index dab71b2f..cf048d7d 100644 --- a/extensions/src/common/animdata.cpp +++ b/extensions/src/common/charactertables.cpp @@ -1,4 +1,4 @@ -#include "extensions/common/animdata.h" +#include "extensions/common/charactertables.h" #include "legopathactor.h" diff --git a/extensions/src/common/pathutils.cpp b/extensions/src/common/pathutils.cpp new file mode 100644 index 00000000..de923713 --- /dev/null +++ b/extensions/src/common/pathutils.cpp @@ -0,0 +1,24 @@ +#include "extensions/common/pathutils.h" + +#include "legomain.h" + +#include + +using namespace Extensions::Common; + +bool Extensions::Common::ResolveGamePath(const char* p_relativePath, MxString& p_outPath) +{ + p_outPath = MxString(MxOmni::GetHD()) + p_relativePath; + p_outPath.MapPathToFilesystem(); + if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) { + return true; + } + + p_outPath = MxString(MxOmni::GetCD()) + p_relativePath; + p_outPath.MapPathToFilesystem(); + if (SDL_GetPathInfo(p_outPath.GetData(), NULL)) { + return true; + } + + return false; +} diff --git a/extensions/src/multiplayer/animation/audioplayer.cpp b/extensions/src/multiplayer/animation/audioplayer.cpp new file mode 100644 index 00000000..95dc88af --- /dev/null +++ b/extensions/src/multiplayer/animation/audioplayer.cpp @@ -0,0 +1,50 @@ +#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) { + 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..20ec04cf --- /dev/null +++ b/extensions/src/multiplayer/animation/catalog.cpp @@ -0,0 +1,228 @@ +#include "extensions/multiplayer/animation/catalog.h" + +#include "decomp.h" +#include "legoanimationmanager.h" +#include "legocharactermanager.h" +#include "misc.h" + +#include + +using namespace Multiplayer::Animation; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +// Exact-match a model name against g_characters[].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. +static int8_t GetCharacterIndex(const char* p_name) +{ + for (int8_t i = 0; i < (int8_t) sizeOfArray(g_characters); i++) { + if (!SDL_strcasecmp(p_name, g_characters[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; +} + +void Catalog::Refresh(LegoAnimationManager* p_am) +{ + m_entries.clear(); + m_locationIndex.clear(); + m_animsBase = nullptr; + m_animCount = 0; + + if (!p_am) { + return; + } + + m_animCount = p_am->m_animCount; + m_animsBase = p_am->m_anims; + + if (!m_animsBase || m_animCount == 0) { + return; + } + + for (uint16_t i = 0; i < m_animCount; i++) { + if (!m_animsBase[i].m_name || m_animsBase[i].m_objectId == 0) { + continue; + } + + CatalogEntry entry; + entry.animIndex = i; + entry.spectatorMask = m_animsBase[i].m_unk0x0c; + entry.location = m_animsBase[i].m_location; + entry.characterIndex = m_animsBase[i].m_characterIndex; + entry.modelCount = m_animsBase[i].m_modelCount; + + if (entry.characterIndex < 0) { + entry.category = e_otherAnim; + } + else if (entry.location == -1) { + entry.category = e_npcAnim; + } + else { + entry.category = e_camAnim; + } + + // Compute performerMask by matching models against g_characters[].m_name + entry.performerMask = 0; + for (uint8_t m = 0; m < entry.modelCount; m++) { + if (m_animsBase[i].m_models && m_animsBase[i].m_models[m].m_name) { + int8_t charIdx = GetCharacterIndex(m_animsBase[i].m_models[m].m_name); + if (charIdx >= 0) { + entry.performerMask |= (uint64_t(1) << charIdx); + } + } + } + + size_t idx = m_entries.size(); + m_entries.push_back(entry); + + // Build location index + m_locationIndex[entry.location].push_back(idx); + } + +} + +const AnimInfo* Catalog::GetAnimInfo(uint16_t p_animIndex) const +{ + if (!m_animsBase || p_animIndex >= m_animCount) { + return nullptr; + } + return &m_animsBase[p_animIndex]; +} + +int8_t Catalog::DisplayActorToCharacterIndex(uint8_t p_displayActorIndex) +{ + const char* actorName = CharacterManager()->GetActorName(p_displayActorIndex); + if (!actorName) { + return -1; + } + + return GetCharacterIndex(actorName); +} + +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::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, + 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)) { + *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; +} diff --git a/extensions/src/multiplayer/animation/coordinator.cpp b/extensions/src/multiplayer/animation/coordinator.cpp new file mode 100644 index 00000000..2bbe34fc --- /dev/null +++ b/extensions/src/multiplayer/animation/coordinator.cpp @@ -0,0 +1,271 @@ +#include "extensions/multiplayer/animation/coordinator.h" + +#include "extensions/multiplayer/animation/catalog.h" +#include "legoanimationmanager.h" + +#include + +using namespace Multiplayer::Animation; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +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_peerId) +{ + m_localPeerId = p_peerId; +} + +void Coordinator::SetInterest(uint16_t p_animIndex) +{ + if (m_state != CoordinationState::e_idle && m_state != CoordinationState::e_interested) { + return; + } + + m_currentAnimIndex = p_animIndex; + 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_characters)) { + slot.names.push_back(g_characters[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_characters[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, + uint8_t p_locationCount, + const int8_t* p_proximityChars, + 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; + } + + // 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; + 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, 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(int16_t p_location, const Catalog* p_catalog) +{ + if (m_state != CoordinationState::e_interested || !p_catalog) { + return; + } + + auto anims = p_catalog->GetAnimationsAtLocation(p_location); + for (const auto* e : anims) { + if (e->animIndex == m_currentAnimIndex) { + return; // still available + } + } + + // Animation not at new 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::ApplySessionUpdate( + uint16_t p_animIndex, + 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_animIndex); + + // If local player was in this session, reset to idle + if (m_currentAnimIndex == p_animIndex && + (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_animIndex]; + 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_animIndex; + m_state = sv.state; + } + else if (!localInSession) { + if (m_currentAnimIndex == p_animIndex) { + m_state = CoordinationState::e_idle; + m_currentAnimIndex = ANIM_INDEX_NONE; + } + m_cancelPending = false; + } + } +} + +void Coordinator::ApplyAnimStart(uint16_t p_animIndex) +{ + if (IsLocalPlayerInSession(p_animIndex)) { + m_state = CoordinationState::e_playing; + m_currentAnimIndex = p_animIndex; + } + + // Update session view so PushAnimationState reads correct values + auto it = m_sessions.find(p_animIndex); + 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..55ed4400 --- /dev/null +++ b/extensions/src/multiplayer/animation/loader.cpp @@ -0,0 +1,442 @@ +#include "extensions/multiplayer/animation/loader.h" + +#include "anim/legoanim.h" +#include "extensions/common/pathutils.h" +#include "flic.h" +#include "misc/legostorage.h" +#include "mxautolock.h" +#include "mxwavepresenter.h" + +#include +#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 += 8; + 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_siFile(nullptr), m_interleaf(nullptr), m_siReady(false), m_preloadThread(nullptr), m_preloadObjectId(0), + m_preloadDone(false) +{ +} + +Loader::~Loader() +{ + CleanupPreloadThread(); + delete m_interleaf; + delete m_siFile; +} + +bool Loader::OpenSIHeaderOnly(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 Loader::OpenSI() +{ + if (m_siReady) { + return true; + } + + if (!OpenSIHeaderOnly("\\lego\\scripts\\isle\\isle.si", m_siFile, m_interleaf)) { + return false; + } + + m_siReady = true; + return true; +} + +bool Loader::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; +} + +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::ParseSoundChild(si::Object* p_child, SceneAnimData& p_data) +{ + 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; + } + + SceneAnimData::AudioTrack track; + SDL_memcpy(&track.format, header.data(), sizeof(MxWavePresenter::WaveFormat)); + track.pcmData = nullptr; + track.pcmDataSize = 0; + track.volume = (int32_t) p_child->volume_; + track.timeOffset = p_child->time_offset_; + track.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; + } + + track.pcmData = new MxU8[totalPcm]; + track.pcmDataSize = totalPcm; + track.format.m_dataSize = totalPcm; + MxU32 offset = 0; + for (size_t i = 1; i < chunks.size(); i++) { + SDL_memcpy(track.pcmData + offset, chunks[i].data(), chunks[i].size()); + offset += (MxU32) chunks[i].size(); + } + + p_data.audioTracks.push_back(std::move(track)); + 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) { + ParseSoundChild(child, p_data); + } + } + + return hasAnim; +} + +SceneAnimData* Loader::EnsureCached(uint32_t p_objectId) +{ + { + AUTOLOCK(m_cacheCS); + auto it = m_cache.find(p_objectId); + 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_preloadObjectId == p_objectId) { + CleanupPreloadThread(); + + AUTOLOCK(m_cacheCS); + auto it = m_cache.find(p_objectId); + if (it != m_cache.end()) { + return &it->second; + } + // Preload failed — fall through to synchronous load + } + + if (!OpenSI()) { + return nullptr; + } + + if (!ReadObject(p_objectId)) { + return nullptr; + } + + si::Object* composite = static_cast(m_interleaf->GetChildAt(p_objectId)); + + SceneAnimData data; + if (!ParseComposite(composite, data)) { + return nullptr; + } + + AUTOLOCK(m_cacheCS); + auto result = m_cache.emplace(p_objectId, std::move(data)); + return &result.first->second; +} + +void Loader::CleanupPreloadThread() +{ + if (m_preloadThread) { + delete m_preloadThread; + m_preloadThread = nullptr; + } +} + +void Loader::PreloadAsync(uint32_t p_objectId) +{ + { + AUTOLOCK(m_cacheCS); + if (m_cache.find(p_objectId) != m_cache.end()) { + return; + } + } + + if (m_preloadThread && m_preloadObjectId == p_objectId && !m_preloadDone) { + return; + } + + CleanupPreloadThread(); + + m_preloadObjectId = p_objectId; + m_preloadDone = false; + m_preloadThread = new PreloadThread(this, p_objectId); + m_preloadThread->Start(0x1000, 0); +} + +Loader::PreloadThread::PreloadThread(Loader* p_loader, uint32_t p_objectId) : m_loader(p_loader), m_objectId(p_objectId) +{ +} + +MxResult Loader::PreloadThread::Run() +{ + si::File* siFile = nullptr; + si::Interleaf* interleaf = nullptr; + + if (!OpenSIHeaderOnly("\\lego\\scripts\\isle\\isle.si", 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)) { + AUTOLOCK(m_loader->m_cacheCS); + m_loader->m_cache.emplace(m_objectId, 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..b5e126bd --- /dev/null +++ b/extensions/src/multiplayer/animation/locationproximity.cpp @@ -0,0 +1,60 @@ +#include "extensions/multiplayer/animation/locationproximity.h" + +#include "decomp.h" +#include "legolocations.h" + +#include + +using namespace Multiplayer::Animation; + +static const float DEFAULT_RADIUS = NPC_ANIM_PROXIMITY; + +// 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_nearestLocation(-1), m_nearestDistance(0.0f), m_radius(DEFAULT_RADIUS) +{ +} + +bool LocationProximity::Update(float p_x, float p_z) +{ + int16_t prev = m_nearestLocation; + m_nearestLocation = ComputeNearest(p_x, p_z, m_radius); + + if (m_nearestLocation >= 0) { + float dx = p_x - g_locations[m_nearestLocation].m_position[0]; + float dz = p_z - g_locations[m_nearestLocation].m_position[2]; + m_nearestDistance = std::sqrt(dx * dx + dz * dz); + } + else { + m_nearestDistance = 0.0f; + } + + return m_nearestLocation != prev; +} + +void LocationProximity::Reset() +{ + m_nearestLocation = -1; + m_nearestDistance = 0.0f; +} + +int16_t LocationProximity::ComputeNearest(float p_x, float p_z, float p_radius) +{ + float bestDist = p_radius; + int16_t bestLocation = -1; + + 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 < bestDist) { + bestDist = dist; + bestLocation = static_cast(i); + } + } + + return bestLocation; +} diff --git a/extensions/src/multiplayer/animation/phonemeplayer.cpp b/extensions/src/multiplayer/animation/phonemeplayer.cpp new file mode 100644 index 00000000..c9542fa2 --- /dev/null +++ b/extensions/src/multiplayer/animation/phonemeplayer.cpp @@ -0,0 +1,169 @@ +#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 in the roiMap. +static LegoROI* FindTrackROI(const std::string& p_roiName, LegoROI** p_roiMap, MxU32 p_roiMapSize) +{ + if (p_roiName.empty() || !p_roiMap) { + return nullptr; + } + + 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) +{ + for (auto& track : p_tracks) { + 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 the roiMap + LegoROI* targetROI = FindTrackROI(track.roiName, p_roiMap, p_roiMapSize); + if (!targetROI) { + m_states.push_back(state); + continue; + } + state.targetROI = targetROI; + + 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, NULL, 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::Cleanup() +{ + for (size_t i = 0; i < m_states.size(); i++) { + auto& state = m_states[i]; + + if (state.targetROI && state.originalTexture) { + CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture); + } + + if (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..74e6dc23 --- /dev/null +++ b/extensions/src/multiplayer/animation/sceneplayer.cpp @@ -0,0 +1,575 @@ +#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 "legoanimationmanager.h" +#include "legocameracontroller.h" +#include "legocharactermanager.h" +#include "legovideomanager.h" +#include "legoworld.h" +#include "misc.h" +#include "misc/legotree.h" +#include "mxgeometry/mxgeometry3d.h" +#include "realtime/realtime.h" +#include "roi/legoroi.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace Multiplayer::Animation; +namespace AnimUtils = Extensions::Common::AnimUtils; +using Extensions::Common::CharacterCloner; + +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +enum VehicleCategory { + e_bike, + e_motorcycle, + e_skateboard, + e_unknownVehicle +}; + +static VehicleCategory GetVehicleCategory(MxU32 p_vehicleIdx) +{ + if (p_vehicleIdx <= 3) { + return e_bike; + } + if (p_vehicleIdx <= 5) { + return e_motorcycle; + } + if (p_vehicleIdx == 6) { + return e_skateboard; + } + return e_unknownVehicle; +} + +static bool MatchesCharacter(const std::string& p_actorName, int8_t p_charIndex) +{ + if (p_charIndex < 0 || p_charIndex >= (int8_t) sizeOfArray(g_characters)) { + return false; + } + return !SDL_strcasecmp(p_actorName.c_str(), g_characters[p_charIndex].m_name); +} + +ScenePlayer::ScenePlayer() + : 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); + + auto addAlias = [&](const std::string& p_name, LegoROI* p_roi) { + aliasNames.push_back(p_name); + aliases.push_back({aliasNames.back().c_str(), p_roi}); + }; + + auto createProp = [&](const std::string& p_name, const char* p_lodName) -> LegoROI* { + char uniqueName[64]; + SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_prop_%s", p_name.c_str()); + LegoROI* roi = CharacterManager()->CreateAutoROI(uniqueName, p_lodName, FALSE); + if (roi) { + roi->SetName(p_name.c_str()); + createdROIs.push_back(roi); + } + return roi; + }; + + 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) { + 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) { + continue; + } + + // No participant matched — create a clone + char uniqueName[64]; + SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_char_%s", lowered.c_str()); + LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str()); + if (roi) { + roi->SetName(lowered.c_str()); + VideoManager()->Get3DManager()->Add(*roi); + createdROIs.push_back(roi); + } + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoiTrimmed || actorType == LegoAnimActorEntry::e_sceneRoi1 || actorType == LegoAnimActorEntry::e_sceneRoi2) { + createProp(lowered, AnimUtils::TrimLODSuffix(lowered).c_str()); + } + else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoi) { + createProp(lowered, lowered.c_str()); + } + else { + // Type 0/1: check if this is a vehicle actor via ModelInfo flag + LegoROI* roi = nullptr; + 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 (GetVehicleCategory(animVehicleIdx) == GetVehicleCategory(perfVehicleIdx)) { + m_vehicleROI = m_participants[p].vehicleROI; + addAlias(lowered, m_vehicleROI); + roi = m_vehicleROI; + break; + } + } + } + } + } + + // Try creating as prop + if (!roi) { + roi = createProp(lowered, AnimUtils::TrimLODSuffix(lowered).c_str()); + } + + // 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); + } + 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, + 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_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_audioPlayer.Init(data->audioTracks); + + // Observers don't get camera control — they watch the animation from their own viewpoint + m_hasCamAnim = (!m_observerMode && m_category == e_camAnim && m_currentData->anim->GetCamAnim() != nullptr); + + if (m_category == e_camAnim && !m_observerMode) { + for (auto& p : m_participants) { + if (p.IsSpectator()) { + p.roi->SetVisibility(FALSE); + } + } + + // 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; +} + +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; + } + + LegoWorld* world = CurrentWorld(); + if (!world || !world->GetCameraController()) { + return; + } + + // Same math as LegoAnimPresenter::PutFrame + for (LegoROI* roi : m_ptAtCamROIs) { + if (!roi) { + continue; + } + + MxMatrix mat(roi->GetLocal2World()); + + Vector3 pos(mat[0]); + Vector3 dir(mat[1]); + Vector3 up(mat[2]); + Vector3 und(mat[3]); + + float possqr = sqrt(pos.LenSquared()); + float dirsqr = sqrt(dir.LenSquared()); + float upsqr = sqrt(up.LenSquared()); + + up = und; + up -= world->GetCameraController()->GetWorldLocation(); + dir /= dirsqr; + pos.EqualsCross(dir, up); + pos.Unitize(); + up.EqualsCross(pos, dir); + pos *= possqr; + dir *= dirsqr; + up *= upsqr; + + roi->SetLocal2World(mat); + roi->WrappedUpdateWorldData(); + } +} + +void ScenePlayer::Tick() +{ + if (!m_playing || !m_currentData || m_participants.empty()) { + return; + } + + if (m_startTime == 0) { + m_startTime = SDL_GetTicks(); + } + + if (m_category == e_npcAnim && m_roiMap) { + AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize); + } + + float elapsed = (float) (SDL_GetTicks() - m_startTime); + + if (elapsed >= m_currentData->duration) { + Stop(); + return; + } + + // 1. Skeletal animation + if (m_currentData->anim && m_roiMap) { + if (!m_rebaseComputed) { + if (m_category == e_camAnim) { + // cam_anims use the action transform directly (keyframes are in world space) + if (m_currentData->actionTransform.valid) { + Mx3DPointFloat loc( + m_currentData->actionTransform.location[0], + m_currentData->actionTransform.location[1], + m_currentData->actionTransform.location[2] + ); + Mx3DPointFloat dir( + m_currentData->actionTransform.direction[0], + m_currentData->actionTransform.direction[1], + m_currentData->actionTransform.direction[2] + ); + Mx3DPointFloat up( + m_currentData->actionTransform.up[0], + m_currentData->actionTransform.up[1], + m_currentData->actionTransform.up[2] + ); + CalcLocalTransform(loc, dir, up, m_rebaseMatrix); + } + else { + m_rebaseMatrix.SetIdentity(); + } + m_rebaseComputed = true; + } + else { + ComputeRebaseMatrix(); + } + } + + AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap); + } + + // 2. Camera animation (cam_anim only) + if (m_hasCamAnim) { + MxMatrix camTransform(m_rebaseMatrix); + m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform); + + LegoWorld* world = CurrentWorld(); + if (world && world->GetCameraController()) { + world->GetCameraController()->TransformPointOfView(camTransform, FALSE); + } + } + + // 3. PTATCAM post-processing + ApplyPtAtCam(); + + // 4. Audio + const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr; + m_audioPlayer.Tick(elapsed, audioROIName); + + // 5. Phoneme frames + m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks); +} + +void ScenePlayer::Stop() +{ + if (!m_playing) { + return; + } + + m_audioPlayer.Cleanup(); + m_phonemePlayer.Cleanup(); + + if (m_hideOnStop && m_roiMap) { + for (MxU32 i = 1; i < m_roiMapSize; i++) { + if (m_roiMap[i]) { + m_roiMap[i]->SetVisibility(FALSE); + } + } + } + + if (m_hiddenVehicleROI) { + m_hiddenVehicleROI->SetVisibility(TRUE); + m_hiddenVehicleROI = nullptr; + } + + CleanupProps(); + m_vehicleROI = nullptr; + + delete[] m_roiMap; + m_roiMap = nullptr; + m_roiMapSize = 0; + + for (auto& p : m_participants) { + p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform); + p.roi->SetVisibility(TRUE); + } + m_participants.clear(); + + m_ptAtCamROIs.clear(); + m_playing = false; + m_rebaseComputed = false; + m_currentData = nullptr; + m_animRootROI = nullptr; + m_hasCamAnim = false; + m_observerMode = false; + m_startTime = 0; + m_hideOnStop = false; +} + +void ScenePlayer::CleanupProps() +{ + for (auto* propROI : m_propROIs) { + if (propROI) { + CharacterManager()->ReleaseAutoROI(propROI); + } + } + m_propROIs.clear(); +} diff --git a/extensions/src/multiplayer/animation/sessionhost.cpp b/extensions/src/multiplayer/animation/sessionhost.cpp new file mode 100644 index 00000000..343a77e8 --- /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; + } +} + +uint16_t SessionHost::Tick(uint32_t p_now) +{ + for (auto& [animIndex, session] : m_sessions) { + if (session.state == CoordinationState::e_countdown && p_now >= session.countdownEndTime) { + session.state = CoordinationState::e_playing; + return animIndex; + } + } + + return ANIM_INDEX_NONE; +} + +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/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 1244fde9..e1b9f4d1 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -1,11 +1,12 @@ #include "extensions/multiplayer/networkmanager.h" -#include "extensions/common/animdata.h" #include "extensions/common/arearestriction.h" #include "extensions/common/charactercustomizer.h" +#include "extensions/common/charactertables.h" #include "extensions/multiplayer/namebubblerenderer.h" #include "extensions/thirdpersoncamera.h" #include "extensions/thirdpersoncamera/controller.h" +#include "legoactor.h" #include "legoanimationmanager.h" #include "legocharactermanager.h" #include "legoextraactor.h" @@ -30,6 +31,23 @@ using Common::IsMultiPartEmote; using Common::IsRestrictedArea; using Common::WORLD_NOT_VISIBLE; +// Defined in legoanimationmanager.cpp +extern LegoAnimationManager::Character g_characters[47]; + +// 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 = + "{\"location\":-1,\"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) { @@ -49,9 +67,11 @@ NetworkManager::NetworkManager() 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_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false), - m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), m_reconnectDelay(0), - m_nextReconnectTime(0) + m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), + m_playingAnimIndex(Animation::ANIM_INDEX_NONE), m_disableAllNPCs(false), m_showNameBubbles(true), + m_lastCameraEnabled(false), 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) { } @@ -80,8 +100,15 @@ MxResult NetworkManager::Tickle() 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) { + CancelLocalAnimInterest(); + StopScenePlayback(false); + } + if (m_localNameBubble) { if (!cameraEnabled) { m_localNameBubble->SetVisible(false); @@ -106,6 +133,42 @@ MxResult NetworkManager::Tickle() } } + // 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])) { + int16_t loc = m_locationProximity.GetNearestLocation(); + m_animStateDirty = true; + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + m_animCoordinator.OnLocationChanged(loc, &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 = {MSG_ANIM_CANCEL, m_localPeerId, m_sequence++, TARGET_HOST}; + SendMessage(cancelMsg); + } + m_localPendingAnimInterest = -1; + } + } + } + + if (IsHost()) { + TickHostSessions(); + } + else if (m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) { + m_animStateDirty = true; + } + } + if (!m_transport) { return SUCCESS; } @@ -121,6 +184,7 @@ MxResult NetworkManager::Tickle() ProcessIncomingPackets(); UpdateRemotePlayers(0.016f); + TickAnimation(); // Re-read time; ProcessIncomingPackets may have advanced SDL_GetTicks. uint32_t timeoutNow = SDL_GetTicks(); @@ -135,6 +199,18 @@ MxResult NetworkManager::Tickle() 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; } @@ -187,6 +263,7 @@ void NetworkManager::Disconnect() m_transport->Disconnect(); } RemoveAllRemotePlayers(); + ResetAnimationState(); } bool NetworkManager::IsConnected() const @@ -199,6 +276,55 @@ bool NetworkManager::WasRejected() const return m_wasRejected; } +void NetworkManager::ResetAnimationState() +{ + m_animCoordinator.Reset(); + m_animSessionHost.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 = {MSG_ANIM_CANCEL, m_localPeerId, m_sequence++, TARGET_HOST}; + SendMessage(msg); + } + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::StopAnimation() +{ + ResetAnimationState(); + + if (m_scenePlayer.IsPlaying()) { + m_scenePlayer.Stop(); + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + } +} + void NetworkManager::OnWorldEnabled(LegoWorld* p_world) { if (!p_world) { @@ -232,6 +358,15 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) if (m_disableAllNPCs) { EnforceDisableNPCs(); } + + // Refresh animation catalog from the animation manager + if (AnimationManager()) { + m_animCatalog.Refresh(AnimationManager()); + m_animCoordinator.SetCatalog(&m_animCatalog); + m_animSessionHost.SetCatalog(&m_animCatalog); + } + + m_locationProximity.Reset(); } } @@ -246,6 +381,16 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) m_wasInRestrictedArea = false; m_worldSync.SetInIsleWorld(false); + // 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(); @@ -408,6 +553,7 @@ void NetworkManager::ResetStateAfterReconnect() m_sequence = 0; m_lastBroadcastTime = 0; m_worldSync.ResetForReconnect(); + ResetAnimationState(); } void NetworkManager::ProcessPendingRequests() @@ -419,12 +565,17 @@ void NetworkManager::ProcessPendingRequests() if (cam) { if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) { if (cam->IsEnabled()) { + if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + CancelLocalAnimInterest(); + StopScenePlayback(false); + } cam->Disable(); + NotifyThirdPersonChanged(false); } else { cam->Enable(); + NotifyThirdPersonChanged(true); } - NotifyThirdPersonChanged(cam->IsEnabled()); } int walkAnim = m_pendingWalkAnim.exchange(-1, std::memory_order_relaxed); @@ -443,6 +594,51 @@ void NetworkManager::ProcessPendingRequests() } } + 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 = {MSG_ANIM_INTEREST, m_localPeerId, m_sequence++, 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); @@ -528,8 +724,8 @@ void NetworkManager::BroadcastLocalState() msg.customizeFlags |= (frozenId & 0x07) << 2; } - // Zero speed when in any phase of a multi-part emote - if (cam->IsInMultiPartEmote()) { + // Zero speed when in any phase of a multi-part emote or animation playback + if (cam->IsInMultiPartEmote() || cam->IsAnimPlaying()) { msg.speed = 0.0f; } } @@ -555,6 +751,7 @@ void NetworkManager::ProcessIncomingPackets() SDL_memcpy(&assignedId, data + 1, sizeof(uint32_t)); m_localPeerId = assignedId; m_worldSync.SetLocalPeerId(assignedId); + m_animCoordinator.SetLocalPeerId(assignedId); } if (length >= 6) { uint8_t maxActors = data[5]; @@ -633,6 +830,34 @@ void NetworkManager::ProcessIncomingPackets() } break; } + case MSG_ANIM_INTEREST: { + AnimInterestMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_INTEREST) { + HandleAnimInterest(msg.header.peerId, msg.animIndex, msg.displayActorIndex); + } + break; + } + case MSG_ANIM_CANCEL: { + AnimCancelMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_CANCEL) { + HandleAnimCancel(msg.header.peerId); + } + break; + } + case MSG_ANIM_UPDATE: { + AnimUpdateMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_UPDATE) { + HandleAnimUpdate(msg); + } + break; + } + case MSG_ANIM_START: { + AnimStartMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_START) { + HandleAnimStart(msg); + } + break; + } default: break; } @@ -641,8 +866,32 @@ void NetworkManager::ProcessIncomingPackets() void NetworkManager::UpdateRemotePlayers(float p_deltaTime) { + float radius = m_locationProximity.GetRadius(); + int16_t localLoc = m_locationProximity.GetNearestLocation(); + bool anyInIsle = false; + for (auto& [peerId, player] : m_remotePlayers) { player->Tick(p_deltaTime); + + // Derive nearest location from remote player's current position + // Skip players not in the isle world — their position is stale + if (player->IsSpawned() && player->GetROI() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) { + anyInIsle = true; + + int16_t oldLoc = player->GetNearestLocation(); + const float* pos = player->GetROI()->GetWorldPosition(); + int16_t newLoc = Animation::LocationProximity::ComputeNearest(pos[0], pos[2], radius); + player->SetNearestLocation(newLoc); + if (oldLoc != newLoc && (oldLoc == localLoc || newLoc == localLoc)) { + m_animStateDirty = true; + } + } + } + + // 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; } } @@ -685,6 +934,13 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) 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) @@ -696,6 +952,14 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) 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 @@ -715,6 +979,15 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg) 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); + } + } } } @@ -725,8 +998,15 @@ void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg) m_worldSync.SetHost(IsHost()); - if (!IsHost() && oldHost != m_hostPeerId) { - m_worldSync.OnHostChanged(); + 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(); + } } } @@ -784,12 +1064,22 @@ void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) { auto it = m_remotePlayers.find(p_peerId); if (it != m_remotePlayers.end()) { + if (it->second->GetNearestLocation() == m_locationProximity.GetNearestLocation()) { + m_animStateDirty = true; + } 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); + } + } } } @@ -800,6 +1090,7 @@ void NetworkManager::RemoveAllRemotePlayers() } m_remotePlayers.clear(); m_roiToPlayer.clear(); + m_animStateDirty = true; NotifyPlayerCountChanged(); } @@ -893,6 +1184,511 @@ void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType SendMessage(msg); } +void NetworkManager::StopScenePlayback(bool p_unlockRemotes) +{ + if (!m_scenePlayer.IsPlaying()) { + return; + } + + m_scenePlayer.Stop(); + m_playingAnimIndex = Animation::ANIM_INDEX_NONE; + + if (p_unlockRemotes) { + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } +} + +void NetworkManager::TickAnimation() +{ + if (!m_scenePlayer.IsPlaying()) { + return; + } + + m_scenePlayer.Tick(); + + if (!m_scenePlayer.IsPlaying()) { + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + + ThirdPersonCamera::Controller* cam = GetCamera(); + if (cam) { + cam->SetAnimPlaying(false); + } + + if (IsHost() && m_playingAnimIndex != Animation::ANIM_INDEX_NONE) { + m_animSessionHost.EraseSession(m_playingAnimIndex); + BroadcastAnimUpdate(m_playingAnimIndex); // Broadcast cleared state + } + + m_playingAnimIndex = Animation::ANIM_INDEX_NONE; + m_animCoordinator.Reset(); + 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 && GetPeerLocation(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; + } + } + + 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 AnimInfo* ai = m_animCatalog.GetAnimInfo(animIndex); + if (ai) { + m_scenePlayer.PreloadAsync(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 + uint16_t readyAnim = m_animSessionHost.Tick(SDL_GetTicks()); + if (readyAnim != Animation::ANIM_INDEX_NONE) { + BroadcastAnimStart(readyAnim); + HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim)); + } + + // 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 (GetPeerLocation(p_peerId) != entry->location) { + 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; + } + + Animation::CoordinationState oldState = m_animCoordinator.GetState(); + + std::vector changedAnims; + if (m_animSessionHost.HandleCancel(p_peerId, changedAnims)) { + BroadcastChangedSessions(changedAnims); + m_animInterestDirty = true; + } + + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + StopScenePlayback(true); + } +} + +void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg) +{ + if (IsHost()) { + return; // Host already updated its own state + } + + 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 AnimInfo* ai = m_animCatalog.GetAnimInfo(p_msg.animIndex); + if (ai) { + m_scenePlayer.PreloadAsync(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; + } + + if (oldState == Animation::CoordinationState::e_playing && + m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) { + StopScenePlayback(true); + } + + // Stop observer playback when the observed session is cleared + if (m_scenePlayer.IsPlaying() && m_playingAnimIndex == p_msg.animIndex && p_msg.state == 0) { + StopScenePlayback(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)); + + m_animStateDirty = true; + m_animInterestDirty = true; +} + +void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession) +{ + 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.Reset(); + } + 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->SetAnimationLocked(true); + } + } + } + + // 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; + } + + if (!observerMode) { + bool localIsPerformer = (localCharIndex >= 0); + cam->SetAnimPlaying(true, localIsPerformer, [this]() { m_scenePlayer.Stop(); }); + } + + m_scenePlayer.Play(animInfo, entry->category, participants.data(), (uint8_t) participants.size(), observerMode); + + if (!m_scenePlayer.IsPlaying()) { + if (!observerMode) { + cam->SetAnimPlaying(false); + } + // Unlock remote players on failure + for (auto& [peerId, player] : m_remotePlayers) { + player->SetAnimationLocked(false); + } + abortSession(); + return; + } + + m_playingAnimIndex = p_animIndex; + m_localPendingAnimInterest = -1; + m_animStateDirty = true; +} + +AnimUpdateMsg NetworkManager::BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target) +{ + AnimUpdateMsg msg{}; + msg.header = {MSG_ANIM_UPDATE, m_localPeerId, m_sequence++, 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) +{ + AnimStartMsg msg{}; + msg.header = {MSG_ANIM_START, m_localPeerId, m_sequence++, TARGET_BROADCAST}; + msg.animIndex = p_animIndex; + SendMessage(msg); + + // Also update local coordinator + m_animCoordinator.ApplyAnimStart(p_animIndex); +} + +int16_t NetworkManager::GetPeerLocation(uint32_t p_peerId) const +{ + if (p_peerId == m_localPeerId) { + return m_locationProximity.GetNearestLocation(); + } + auto it = m_remotePlayers.find(p_peerId); + if (it != m_remotePlayers.end()) { + return it->second->GetNearestLocation(); + } + return -1; +} + +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->IsSpawned() && it->second->GetROI()) { + const float* pos = it->second->GetROI()->GetWorldPosition(); + p_x = pos[0]; + p_z = pos[2]; + return true; + } + return false; +} + +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->IsSpawned() || !it->second->GetROI() || + it->second->GetWorldId() != (int8_t) LegoOmni::e_act1) { + return false; + } + const float* pos = it->second->GetROI()->GetWorldPosition(); + float dx = pos[0] - p_refX; + float dz = pos[2] - p_refZ; + return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ; +} + +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; + } + int16_t loc = GetPeerLocation(slot.peerId); + if (loc >= 0 && loc != 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; @@ -911,7 +1707,7 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) it->second->GetCustomizeState(), p_msg.changeType == CHANGE_MOOD ); - if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote()) { + if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote() && !m_scenePlayer.IsPlaying()) { it->second->StopClickAnimation(); MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation( it->second->GetROI(), @@ -951,8 +1747,8 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) p_msg.changeType == CHANGE_MOOD ); - // Only play click animation in 3rd person (not visible in 1st person or multi-part emote) - if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsInMultiPartEmote()) { + // Only play click animation in 3rd person (not during multi-part emote or animation playback) + if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsInMultiPartEmote() && !cam->IsAnimPlaying()) { cam->StopClickAnimation(); MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation(cam->GetDisplayROI(), cam->GetCustomizeState()); @@ -961,3 +1757,241 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg) } } } + +// 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 += ",\"objectId\":"; + p_json += std::to_string(p_animInfo->m_objectId); + 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; + } + + int16_t location = m_locationProximity.GetNearestLocation(); + 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]; + + // Build two sets of character indices: + // - locationCharIndices: players at the same location (for cam anims) + // - proximityCharIndices: players within NPC_ANIM_PROXIMITY (for NPC anims) + std::vector locationCharIndices; + std::vector proximityCharIndices; + locationCharIndices.push_back(localCharIndex); + proximityCharIndices.push_back(localCharIndex); + + for (const auto& [peerId, player] : m_remotePlayers) { + if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { + continue; + } + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); + if (player->GetNearestLocation() == location) { + locationCharIndices.push_back(charIdx); + } + // 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)) { + proximityCharIndices.push_back(charIdx); + } + } + + auto eligibility = m_animCoordinator.ComputeEligibility( + location, + locationCharIndices.data(), + static_cast(locationCharIndices.size()), + proximityCharIndices.data(), + static_cast(proximityCharIndices.size()) + ); + + // Build JSON + std::string json; + json.reserve(2048); + json += "{\"location\":"; + json += std::to_string(location); + 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 index 2f055649..6ef1d5f6 100644 --- a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -64,6 +64,20 @@ void EmscriptenCallbacks::OnConnectionStatusChanged(int 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 +} + } // namespace Multiplayer #endif // __EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp index 1b78830b..1ee63978 100644 --- a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -58,6 +58,22 @@ extern "C" } } + 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/native/nativecallbacks.cpp b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp index 75059592..6f834b46 100644 --- a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp +++ b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp @@ -52,6 +52,11 @@ void NativeCallbacks::OnConnectionStatusChanged(int p_status) SDL_Log("[Multiplayer] Connection status: %s", statusStr); } +void NativeCallbacks::OnAnimationsAvailable(const char* p_json) +{ + (void) p_json; +} + } // namespace Multiplayer #endif // !__EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index 59e5944a..bba4c15e 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -29,9 +29,9 @@ using Common::WORLD_NOT_VISIBLE; 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(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), + m_targetWorldId(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), m_nearestLocation(-1), m_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}), - m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true) + m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true), m_animationLocked(false) { m_displayName[0] = '\0'; const char* displayName = GetDisplayActorName(); @@ -197,6 +197,15 @@ void RemotePlayer::Tick(float p_deltaTime) 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 (m_animationLocked) { + if (m_nameBubble) { + m_nameBubble->Update(m_roi); + } + return; + } + UpdateVehicleState(); UpdateTransform(p_deltaTime); diff --git a/extensions/src/siloader.cpp b/extensions/src/siloader.cpp index 4ed087c8..937ecdaf 100644 --- a/extensions/src/siloader.cpp +++ b/extensions/src/siloader.cpp @@ -1,5 +1,6 @@ #include "extensions/siloader.h" +#include "extensions/common/pathutils.h" #include "legovideomanager.h" #include "misc.h" #include "mxdsaction.h" @@ -240,15 +241,11 @@ bool SiLoaderExt::LoadFile(const char* p_file) si::Interleaf si; MxStreamController* controller; - MxString path = MxString(MxOmni::GetHD()) + p_file; - path.MapPathToFilesystem(); - if (si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { - path = MxString(MxOmni::GetCD()) + p_file; - path.MapPathToFilesystem(); - if (si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { - SDL_Log("Could not parse SI file %s", p_file); - return false; - } + MxString path; + if (!Common::ResolveGamePath(p_file, path) || + si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) { + SDL_Log("Could not parse SI file %s", p_file); + return false; } if (!(controller = OpenStream(p_file))) { diff --git a/extensions/src/textureloader.cpp b/extensions/src/textureloader.cpp index 7542db33..41550a1b 100644 --- a/extensions/src/textureloader.cpp +++ b/extensions/src/textureloader.cpp @@ -1,5 +1,6 @@ #include "extensions/textureloader.h" +#include "extensions/common/pathutils.h" #include "legovideomanager.h" #include "misc.h" #include "mxdirectx/mxdirect3d.h" @@ -115,16 +116,13 @@ SDL_Surface* TextureLoaderExt::FindTexture(const char* p_name) return nullptr; } - SDL_Surface* surface; const char* texturePath = options["texture loader:texture path"].c_str(); - MxString path = MxString(MxOmni::GetHD()) + texturePath + "/" + p_name + ".bmp"; + MxString relativePath = MxString(texturePath) + "/" + p_name + ".bmp"; - path.MapPathToFilesystem(); - if (!(surface = SDL_LoadBMP(path.GetData()))) { - path = MxString(MxOmni::GetCD()) + texturePath + "/" + p_name + ".bmp"; - path.MapPathToFilesystem(); - surface = SDL_LoadBMP(path.GetData()); + MxString path; + if (!Common::ResolveGamePath(relativePath.GetData(), path)) { + return nullptr; } - return surface; + return SDL_LoadBMP(path.GetData()); } diff --git a/extensions/src/thirdpersoncamera.cpp b/extensions/src/thirdpersoncamera.cpp index 70aac3f1..92e4f7e2 100644 --- a/extensions/src/thirdpersoncamera.cpp +++ b/extensions/src/thirdpersoncamera.cpp @@ -123,7 +123,7 @@ void ThirdPersonCameraExt::OnSDLEvent(SDL_Event* p_event) s_camera->SetLmbForwardEngaged(false); } - if (s_camera->ConsumeAutoDisable()) { + if (s_camera->ConsumeAutoDisable() && !s_camera->IsAnimPlaying()) { s_camera->Disable(/*p_preserveTouch=*/true); if (s_camera->IsLeftButtonHeld()) { s_camera->SetLmbForwardEngaged(true); diff --git a/extensions/src/thirdpersoncamera/controller.cpp b/extensions/src/thirdpersoncamera/controller.cpp index a7a912ad..ad44a164 100644 --- a/extensions/src/thirdpersoncamera/controller.cpp +++ b/extensions/src/thirdpersoncamera/controller.cpp @@ -30,7 +30,9 @@ using namespace Extensions::ThirdPersonCamera; Controller::Controller() : m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true, /*.propSuffix=*/0}), m_enabled(false), - m_active(false), m_pendingWorldTransition(false), m_lmbForwardEngaged(false), m_playerROI(nullptr) + m_active(false), m_pendingWorldTransition(false), m_animPlaying(false), m_animLockDisplay(false), + m_lmbForwardEngaged(false), + m_playerROI(nullptr) { } @@ -51,6 +53,15 @@ void Controller::Disable(bool p_preserveTouch) void Controller::Deactivate() { + // Stop external animation before destroying the display ROI + if (m_animPlaying) { + if (m_animStopCallback) { + m_animStopCallback(); + } + m_animPlaying = false; + m_animStopCallback = nullptr; + } + if (m_active && m_playerROI) { m_playerROI->SetVisibility(FALSE); VideoManager()->Get3DManager()->Remove(*m_playerROI); @@ -201,12 +212,13 @@ void Controller::Tick(float p_deltaTime) } } - if (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled) { + if (!m_animPlaying && (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled)) { m_orbit.ApplyOrbitCamera(); } - // Small vehicle with ride animation - if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) { + // Small vehicle with ride animation (skip when external animation is active — + // the animation controller handles positioning the player and vehicle ROI) + if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE && !m_animPlaying) { m_animator.StopClickAnimation(); if (m_animator.GetRideAnim() && m_animator.GetRideRoiMap()) { LegoPathActor* actor = UserActor(); @@ -232,15 +244,7 @@ void Controller::Tick(float p_deltaTime) float timeInCycle = m_animator.GetAnimTime() - duration * SDL_floorf(m_animator.GetAnimTime() / duration); - LegoTreeNode* root = m_animator.GetRideAnim()->GetRoot(); - for (LegoU32 i = 0; i < root->GetNumChildren(); i++) { - LegoROI::ApplyAnimationTransformation( - root->GetChild(i), - transform, - (LegoTime) timeInCycle, - m_animator.GetRideRoiMap() - ); - } + AnimUtils::ApplyTree(m_animator.GetRideAnim(), transform, (LegoTime) timeInCycle, m_animator.GetRideRoiMap()); } } return; @@ -251,6 +255,17 @@ 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 the local player is spectating, still sync + idle animate. + if (m_animPlaying) { + userActor->SetWorldSpeed(0.0f); + NavController()->SetLinearVel(0.0f); + if (m_animLockDisplay) { + return; + } + } + // Sync display clone position from native ROI if (m_display.GetDisplayROI() && m_display.GetDisplayROI() == m_playerROI) { m_display.SyncTransformFromNative(userActor->GetROI()); @@ -340,6 +355,16 @@ void Controller::OnWorldDisabled(LegoWorld* p_world) if (!p_world) { return; } + + // Stop external animation before destroying the display ROI + if (m_animPlaying) { + if (m_animStopCallback) { + m_animStopCallback(); + } + m_animPlaying = false; + m_animStopCallback = nullptr; + } + m_active = false; m_pendingWorldTransition = false; m_playerROI = nullptr; @@ -366,7 +391,7 @@ MxBool Controller::HandleCameraRelativeMovement( p_newPos, p_newDir, p_deltaTime, - m_animator.IsInMultiPartEmote(), + m_animator.IsInMultiPartEmote() || m_animPlaying, m_input.IsLeftButtonHeld() ); }