mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 02:23:56 +00:00
Claude/npc animations local playback (#20)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Remove dta.py accidentally committed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * WIP: ScenePlayer multi-participant support and cam_anim playback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * WIP: Vehicle ROI support, alias-based ROI mapping, audio fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bb9022190
commit
647bd28a07
4
3rdparty/CMakeLists.txt
vendored
4
3rdparty/CMakeLists.txt
vendored
@ -55,8 +55,8 @@ if(DOWNLOAD_DEPENDENCIES)
|
|||||||
include(FetchContent)
|
include(FetchContent)
|
||||||
FetchContent_Populate(
|
FetchContent_Populate(
|
||||||
libweaver
|
libweaver
|
||||||
URL https://github.com/isledecomp/SIEdit/archive/afd4933844b95ef739a7e77b097deb7efe4ec576.tar.gz
|
URL https://github.com/isledecomp/SIEdit/archive/17c7736a6ff31413f1e74ab4e989011b545b6926.tar.gz
|
||||||
URL_MD5 59fd3c36f4f380f730cd9bedfc846397
|
URL_MD5 04edbc974df8884f283d920ded10f1f6
|
||||||
)
|
)
|
||||||
add_library(libweaver STATIC
|
add_library(libweaver STATIC
|
||||||
${libweaver_SOURCE_DIR}/lib/core.cpp
|
${libweaver_SOURCE_DIR}/lib/core.cpp
|
||||||
|
|||||||
@ -534,12 +534,13 @@ if (ISLE_EXTENSIONS)
|
|||||||
extensions/src/textureloader.cpp
|
extensions/src/textureloader.cpp
|
||||||
|
|
||||||
# Common shared code
|
# Common shared code
|
||||||
extensions/src/common/animdata.cpp
|
extensions/src/common/charactertables.cpp
|
||||||
extensions/src/common/animutils.cpp
|
extensions/src/common/animutils.cpp
|
||||||
extensions/src/common/characteranimator.cpp
|
extensions/src/common/characteranimator.cpp
|
||||||
extensions/src/common/charactercloner.cpp
|
extensions/src/common/charactercloner.cpp
|
||||||
extensions/src/common/charactercustomizer.cpp
|
extensions/src/common/charactercustomizer.cpp
|
||||||
extensions/src/common/customizestate.cpp
|
extensions/src/common/customizestate.cpp
|
||||||
|
extensions/src/common/pathutils.cpp
|
||||||
|
|
||||||
# Third person camera extension
|
# Third person camera extension
|
||||||
extensions/src/thirdpersoncamera.cpp
|
extensions/src/thirdpersoncamera.cpp
|
||||||
@ -549,6 +550,14 @@ if (ISLE_EXTENSIONS)
|
|||||||
extensions/src/thirdpersoncamera/displayactor.cpp
|
extensions/src/thirdpersoncamera/displayactor.cpp
|
||||||
|
|
||||||
# Multiplayer extension
|
# 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.cpp
|
||||||
extensions/src/multiplayer/namebubblerenderer.cpp
|
extensions/src/multiplayer/namebubblerenderer.cpp
|
||||||
extensions/src/multiplayer/networkmanager.cpp
|
extensions/src/multiplayer/networkmanager.cpp
|
||||||
|
|||||||
@ -205,6 +205,7 @@ class LegoAnimationManager : public MxCore {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
friend class Multiplayer::NetworkManager;
|
friend class Multiplayer::NetworkManager;
|
||||||
|
friend class Multiplayer::Animation::Catalog;
|
||||||
|
|
||||||
void Init();
|
void Init();
|
||||||
MxResult FUN_100605e0(
|
MxResult FUN_100605e0(
|
||||||
|
|||||||
@ -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(
|
void BuildROIMap(
|
||||||
LegoAnim* p_anim,
|
LegoAnim* p_anim,
|
||||||
LegoROI* p_rootROI,
|
LegoROI* p_rootROI,
|
||||||
LegoROI** p_extraROIs,
|
LegoROI** p_extraROIs,
|
||||||
int p_extraROICount,
|
int p_extraROICount,
|
||||||
LegoROI**& p_roiMap,
|
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<std::string>& p_unmatchedNames);
|
void CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector<std::string>& 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.
|
// Flip a matrix from forward-z to backward-z (or vice versa) in place.
|
||||||
inline void FlipMatrixDirection(MxMatrix& p_mat)
|
inline void FlipMatrixDirection(MxMatrix& p_mat)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "extensions/common/animdata.h"
|
|
||||||
#include "extensions/common/animutils.h"
|
#include "extensions/common/animutils.h"
|
||||||
|
#include "extensions/common/charactertables.h"
|
||||||
#include "mxgeometry/mxmatrix.h"
|
#include "mxgeometry/mxmatrix.h"
|
||||||
#include "mxtypes.h"
|
#include "mxtypes.h"
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ namespace Extensions
|
|||||||
namespace Common
|
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 char* const g_walkAnimNames[];
|
||||||
extern const int g_walkAnimCount;
|
extern const int g_walkAnimCount;
|
||||||
|
|
||||||
17
extensions/include/extensions/common/pathutils.h
Normal file
17
extensions/include/extensions/common/pathutils.h
Normal file
@ -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
|
||||||
@ -20,6 +20,11 @@ namespace Multiplayer
|
|||||||
{
|
{
|
||||||
class NetworkManager;
|
class NetworkManager;
|
||||||
class WorldStateSync;
|
class WorldStateSync;
|
||||||
|
namespace Animation
|
||||||
|
{
|
||||||
|
class Catalog;
|
||||||
|
class Controller;
|
||||||
|
} // namespace Animation
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|
||||||
#endif // EXTENSIONS_FWD_H
|
#endif // EXTENSIONS_FWD_H
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/animation/loader.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class LegoCacheSound;
|
||||||
|
|
||||||
|
namespace Multiplayer::Animation
|
||||||
|
{
|
||||||
|
|
||||||
|
class AudioPlayer {
|
||||||
|
public:
|
||||||
|
// Create LegoCacheSound objects from SceneAnimData's audio tracks
|
||||||
|
void Init(const std::vector<SceneAnimData::AudioTrack>& p_tracks);
|
||||||
|
|
||||||
|
// Start sounds whose time offset has been reached
|
||||||
|
void Tick(float p_elapsedMs, const char* p_roiName);
|
||||||
|
|
||||||
|
// Stop and delete all sounds
|
||||||
|
void Cleanup();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct ActiveSound {
|
||||||
|
LegoCacheSound* sound;
|
||||||
|
uint32_t timeOffset;
|
||||||
|
bool started;
|
||||||
|
};
|
||||||
|
std::vector<ActiveSound> m_activeSounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<int8_t> 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<const CatalogEntry*> GetAnimationsAtLocation(int16_t p_location) const;
|
||||||
|
|
||||||
|
// Check if a player can fill any role (spectator or participant) in this animation.
|
||||||
|
// Accepts a display actor index (converted to g_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<CatalogEntry> m_entries;
|
||||||
|
std::map<int16_t, std::vector<size_t>> m_locationIndex; // location ID → indices into m_entries
|
||||||
|
AnimInfo* m_animsBase;
|
||||||
|
uint16_t m_animCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Multiplayer::Animation
|
||||||
|
{
|
||||||
|
|
||||||
|
class Catalog;
|
||||||
|
struct CatalogEntry;
|
||||||
|
|
||||||
|
enum class CoordinationState : uint8_t {
|
||||||
|
e_idle,
|
||||||
|
e_interested,
|
||||||
|
e_countdown,
|
||||||
|
e_playing
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SlotInfo {
|
||||||
|
// Character names that can fill this slot.
|
||||||
|
// Performer slots: always 1 name (the specific character).
|
||||||
|
// Spectator slot: ["any"] if ALL_CORE_ACTORS_MASK, otherwise the specific allowed names.
|
||||||
|
std::vector<const char*> names;
|
||||||
|
bool filled;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EligibilityInfo {
|
||||||
|
uint16_t animIndex;
|
||||||
|
bool eligible; // All requirements met: at location and all roles filled
|
||||||
|
bool atLocation; // At the right location (or location == -1)
|
||||||
|
const CatalogEntry* entry; // Pointer into catalog (valid until next Refresh)
|
||||||
|
std::vector<SlotInfo> slots; // All role slots (performers + spectator), filled status each
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SessionView {
|
||||||
|
CoordinationState state;
|
||||||
|
uint16_t countdownMs;
|
||||||
|
uint32_t countdownEndTime; // SDL_GetTicks() timestamp when countdown expires (client-side)
|
||||||
|
uint32_t peerSlots[8]; // peerId per slot (matches AnimUpdateMsg layout)
|
||||||
|
uint8_t slotCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Coordinator {
|
||||||
|
public:
|
||||||
|
Coordinator();
|
||||||
|
|
||||||
|
void SetCatalog(const Catalog* p_catalog);
|
||||||
|
|
||||||
|
CoordinationState GetState() const { return m_state; }
|
||||||
|
uint16_t GetCurrentAnimIndex() const { return m_currentAnimIndex; }
|
||||||
|
|
||||||
|
void SetLocalPeerId(uint32_t p_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<EligibilityInfo> 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<uint16_t, SessionView> m_sessions;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
114
extensions/include/extensions/multiplayer/animation/loader.h
Normal file
114
extensions/include/extensions/multiplayer/animation/loader.h
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "mxcriticalsection.h"
|
||||||
|
#include "mxthread.h"
|
||||||
|
#include "mxwavepresenter.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct FLIC_HEADER;
|
||||||
|
class LegoAnim;
|
||||||
|
|
||||||
|
namespace si
|
||||||
|
{
|
||||||
|
class 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<AudioTrack> audioTracks;
|
||||||
|
|
||||||
|
struct PhonemeTrack {
|
||||||
|
FLIC_HEADER* flcHeader;
|
||||||
|
std::vector<std::vector<char>> frameData;
|
||||||
|
uint32_t timeOffset;
|
||||||
|
std::string roiName;
|
||||||
|
uint16_t width, height;
|
||||||
|
};
|
||||||
|
std::vector<PhonemeTrack> phonemeTracks;
|
||||||
|
|
||||||
|
// Action transform from SI metadata (location/direction/up)
|
||||||
|
struct {
|
||||||
|
float location[3];
|
||||||
|
float direction[3];
|
||||||
|
float up[3];
|
||||||
|
bool valid;
|
||||||
|
} actionTransform;
|
||||||
|
|
||||||
|
std::vector<std::string> ptAtCamNames; // ROI names from PTATCAM directive
|
||||||
|
bool hideOnStop;
|
||||||
|
|
||||||
|
SceneAnimData();
|
||||||
|
~SceneAnimData();
|
||||||
|
|
||||||
|
SceneAnimData(const SceneAnimData&) = delete;
|
||||||
|
SceneAnimData& operator=(const SceneAnimData&) = delete;
|
||||||
|
SceneAnimData(SceneAnimData&& p_other) noexcept;
|
||||||
|
SceneAnimData& operator=(SceneAnimData&& p_other) noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void ReleaseTracks();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loads animation data from 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<uint32_t, SceneAnimData> m_cache;
|
||||||
|
MxCriticalSection m_cacheCS;
|
||||||
|
|
||||||
|
PreloadThread* m_preloadThread;
|
||||||
|
uint32_t m_preloadObjectId;
|
||||||
|
std::atomic<bool> m_preloadDone;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/animation/loader.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class LegoROI;
|
||||||
|
class LegoTextureInfo;
|
||||||
|
class MxBitmap;
|
||||||
|
|
||||||
|
namespace Multiplayer::Animation
|
||||||
|
{
|
||||||
|
|
||||||
|
struct PhonemeState {
|
||||||
|
LegoROI* targetROI;
|
||||||
|
LegoTextureInfo* originalTexture;
|
||||||
|
LegoTextureInfo* cachedTexture;
|
||||||
|
MxBitmap* bitmap;
|
||||||
|
int32_t currentFrame;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PhonemePlayer {
|
||||||
|
public:
|
||||||
|
void Init(const std::vector<SceneAnimData::PhonemeTrack>& p_tracks, LegoROI** p_roiMap, MxU32 p_roiMapSize);
|
||||||
|
void Tick(float p_elapsedMs, const std::vector<SceneAnimData::PhonemeTrack>& p_tracks);
|
||||||
|
void Cleanup();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<PhonemeState> m_states;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -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 <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class LegoROI;
|
||||||
|
struct AnimInfo;
|
||||||
|
|
||||||
|
namespace Multiplayer::Animation
|
||||||
|
{
|
||||||
|
|
||||||
|
// A participant (local or remote player) whose ROI is borrowed during animation
|
||||||
|
struct ParticipantROI {
|
||||||
|
LegoROI* roi;
|
||||||
|
LegoROI* vehicleROI; // Ride vehicle ROI (bike/board/moto), or nullptr
|
||||||
|
MxMatrix savedTransform;
|
||||||
|
std::string savedName;
|
||||||
|
int8_t charIndex; // g_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<ParticipantROI> m_participants;
|
||||||
|
|
||||||
|
// Root performer ROI (rebase anchor for NPC anims)
|
||||||
|
LegoROI* m_animRootROI;
|
||||||
|
|
||||||
|
// Vehicle ROI borrowed from a participant during playback
|
||||||
|
LegoROI* m_vehicleROI;
|
||||||
|
|
||||||
|
// Player's ride vehicle hidden during cam_anim (not borrowed, just hidden)
|
||||||
|
LegoROI* m_hiddenVehicleROI;
|
||||||
|
|
||||||
|
// ROI map for skeletal animation
|
||||||
|
LegoROI** m_roiMap;
|
||||||
|
MxU32 m_roiMapSize;
|
||||||
|
|
||||||
|
// Props created for the animation (cloned characters and prop models)
|
||||||
|
std::vector<LegoROI*> m_propROIs;
|
||||||
|
|
||||||
|
bool m_hasCamAnim;
|
||||||
|
bool m_observerMode;
|
||||||
|
std::vector<LegoROI*> m_ptAtCamROIs;
|
||||||
|
bool m_hideOnStop;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Multiplayer::Animation
|
||||||
|
{
|
||||||
|
|
||||||
|
class Catalog;
|
||||||
|
struct CatalogEntry;
|
||||||
|
enum class CoordinationState : uint8_t;
|
||||||
|
|
||||||
|
struct SessionSlot {
|
||||||
|
uint32_t peerId; // 0 = unfilled
|
||||||
|
int8_t charIndex; // g_characters index, or -1 for spectator
|
||||||
|
|
||||||
|
bool IsSpectator() const { return charIndex < 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AnimSession {
|
||||||
|
uint16_t animIndex;
|
||||||
|
CoordinationState state;
|
||||||
|
std::vector<SessionSlot> slots;
|
||||||
|
uint32_t countdownEndTime; // SDL_GetTicks timestamp when countdown expires
|
||||||
|
};
|
||||||
|
|
||||||
|
class SessionHost {
|
||||||
|
public:
|
||||||
|
void SetCatalog(const Catalog* p_catalog);
|
||||||
|
|
||||||
|
bool HandleInterest(
|
||||||
|
uint32_t p_peerId,
|
||||||
|
uint16_t p_animIndex,
|
||||||
|
uint8_t p_displayActorIndex,
|
||||||
|
std::vector<uint16_t>& p_changedAnims);
|
||||||
|
bool HandleCancel(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
|
||||||
|
bool HandlePlayerRemoved(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
|
||||||
|
|
||||||
|
// Returns 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<uint16_t, AnimSession>& GetSessions() const;
|
||||||
|
bool AreAllSlotsFilled(uint16_t p_animIndex) const;
|
||||||
|
|
||||||
|
static uint16_t ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now);
|
||||||
|
|
||||||
|
// Reconstruct slot charIndex assignments from CatalogEntry::performerMask.
|
||||||
|
// Same iteration order as CreateSession — deterministic across all clients.
|
||||||
|
static std::vector<int8_t> ComputeSlotCharIndices(const CatalogEntry* p_entry);
|
||||||
|
|
||||||
|
bool HasCountdownSession() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
AnimSession CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex);
|
||||||
|
bool TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex);
|
||||||
|
bool AllSlotsFilled(const AnimSession& p_session) const;
|
||||||
|
void RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims);
|
||||||
|
void RemovePlayerFromSessions(
|
||||||
|
uint32_t p_peerId,
|
||||||
|
bool p_includePlayingSessions,
|
||||||
|
std::vector<uint16_t>& p_changedAnims);
|
||||||
|
|
||||||
|
const Catalog* m_catalog = nullptr;
|
||||||
|
std::map<uint16_t, AnimSession> m_sessions;
|
||||||
|
|
||||||
|
static const uint32_t COUNTDOWN_DURATION_MS = 4000;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Multiplayer::Animation
|
||||||
@ -1,5 +1,10 @@
|
|||||||
#pragma once
|
#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/networktransport.h"
|
||||||
#include "extensions/multiplayer/platformcallbacks.h"
|
#include "extensions/multiplayer/platformcallbacks.h"
|
||||||
#include "extensions/multiplayer/protocol.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 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 RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); }
|
||||||
void RequestToggleAllowCustomize() { m_pendingToggleAllowCustomize.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 IsInIsleWorld() const { return m_inIsleWorld; }
|
||||||
bool GetShowNameBubbles() const { return m_showNameBubbles; }
|
bool GetShowNameBubbles() const { return m_showNameBubbles; }
|
||||||
@ -83,6 +93,10 @@ class NetworkManager : public MxCore {
|
|||||||
bool IsClonedCharacter(const char* p_name) const;
|
bool IsClonedCharacter(const char* p_name) const;
|
||||||
void SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex);
|
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 OnWorldEnabled(LegoWorld* p_world);
|
||||||
void OnWorldDisabled(LegoWorld* p_world);
|
void OnWorldDisabled(LegoWorld* p_world);
|
||||||
void OnBeforeSaveLoad();
|
void OnBeforeSaveLoad();
|
||||||
@ -116,6 +130,26 @@ class NetworkManager : public MxCore {
|
|||||||
void HandleEmote(const EmoteMsg& p_msg);
|
void HandleEmote(const EmoteMsg& p_msg);
|
||||||
void HandleCustomize(const CustomizeMsg& 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<uint16_t>& p_changedAnims);
|
||||||
|
void TickHostSessions();
|
||||||
|
|
||||||
void ProcessPendingRequests();
|
void ProcessPendingRequests();
|
||||||
void RemoveRemotePlayer(uint32_t p_peerId);
|
void RemoveRemotePlayer(uint32_t p_peerId);
|
||||||
void RemoveAllRemotePlayers();
|
void RemoveAllRemotePlayers();
|
||||||
@ -126,6 +160,7 @@ class NetworkManager : public MxCore {
|
|||||||
|
|
||||||
void NotifyPlayerCountChanged();
|
void NotifyPlayerCountChanged();
|
||||||
void EnforceDisableNPCs();
|
void EnforceDisableNPCs();
|
||||||
|
void PushAnimationState();
|
||||||
|
|
||||||
// Serialize and send a fixed-size message via the transport
|
// Serialize and send a fixed-size message via the transport
|
||||||
template <typename T>
|
template <typename T>
|
||||||
@ -153,12 +188,31 @@ class NetworkManager : public MxCore {
|
|||||||
std::atomic<int> m_pendingIdleAnim;
|
std::atomic<int> m_pendingIdleAnim;
|
||||||
std::atomic<int> m_pendingEmote;
|
std::atomic<int> m_pendingEmote;
|
||||||
std::atomic<bool> m_pendingToggleAllowCustomize;
|
std::atomic<bool> m_pendingToggleAllowCustomize;
|
||||||
|
std::atomic<int32_t> m_pendingAnimInterest;
|
||||||
|
std::atomic<bool> m_pendingAnimCancel;
|
||||||
|
|
||||||
bool m_disableAllNPCs;
|
bool m_disableAllNPCs;
|
||||||
bool m_showNameBubbles;
|
bool m_showNameBubbles;
|
||||||
bool m_lastCameraEnabled;
|
bool m_lastCameraEnabled;
|
||||||
bool m_wasInRestrictedArea;
|
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;
|
ConnectionState m_connectionState;
|
||||||
bool m_wasRejected;
|
bool m_wasRejected;
|
||||||
std::string m_roomId;
|
std::string m_roomId;
|
||||||
@ -166,11 +220,12 @@ class NetworkManager : public MxCore {
|
|||||||
uint32_t m_reconnectDelay;
|
uint32_t m_reconnectDelay;
|
||||||
uint32_t m_nextReconnectTime;
|
uint32_t m_nextReconnectTime;
|
||||||
|
|
||||||
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
|
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
|
||||||
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
||||||
static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000;
|
static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||||
static const uint32_t RECONNECT_MAX_DELAY_MS = 30000;
|
static const uint32_t RECONNECT_MAX_DELAY_MS = 30000;
|
||||||
static const uint32_t RECONNECT_MAX_ATTEMPTS = 10;
|
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
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
namespace Multiplayer
|
namespace Multiplayer
|
||||||
{
|
{
|
||||||
|
|
||||||
@ -27,6 +29,10 @@ class PlatformCallbacks {
|
|||||||
|
|
||||||
// Called when the connection status changes (connected, reconnecting, failed).
|
// Called when the connection status changes (connected, reconnecting, failed).
|
||||||
virtual void OnConnectionStatusChanged(int p_status) = 0;
|
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
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class EmscriptenCallbacks : public PlatformCallbacks {
|
|||||||
void OnNameBubblesChanged(bool p_enabled) override;
|
void OnNameBubblesChanged(bool p_enabled) override;
|
||||||
void OnAllowCustomizeChanged(bool p_enabled) override;
|
void OnAllowCustomizeChanged(bool p_enabled) override;
|
||||||
void OnConnectionStatusChanged(int p_status) override;
|
void OnConnectionStatusChanged(int p_status) override;
|
||||||
|
void OnAnimationsAvailable(const char* p_json) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class NativeCallbacks : public PlatformCallbacks {
|
|||||||
void OnNameBubblesChanged(bool p_enabled) override;
|
void OnNameBubblesChanged(bool p_enabled) override;
|
||||||
void OnAllowCustomizeChanged(bool p_enabled) override;
|
void OnAllowCustomizeChanged(bool p_enabled) override;
|
||||||
void OnConnectionStatusChanged(int p_status) override;
|
void OnConnectionStatusChanged(int p_status) override;
|
||||||
|
void OnAnimationsAvailable(const char* p_json) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -25,6 +25,10 @@ enum MessageType : uint8_t {
|
|||||||
MSG_WORLD_EVENT_REQUEST = 8,
|
MSG_WORLD_EVENT_REQUEST = 8,
|
||||||
MSG_EMOTE = 9,
|
MSG_EMOTE = 9,
|
||||||
MSG_CUSTOMIZE = 10,
|
MSG_CUSTOMIZE = 10,
|
||||||
|
MSG_ANIM_INTEREST = 11,
|
||||||
|
MSG_ANIM_CANCEL = 12,
|
||||||
|
MSG_ANIM_UPDATE = 13,
|
||||||
|
MSG_ANIM_START = 14,
|
||||||
MSG_ASSIGN_ID = 0xFF
|
MSG_ASSIGN_ID = 0xFF
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,6 +154,39 @@ struct CustomizeMsg {
|
|||||||
uint8_t partIndex; // Body part for color changes (0-9), 0xFF otherwise
|
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)
|
#pragma pack(pop)
|
||||||
|
|
||||||
using Extensions::Common::IsValidActorId;
|
using Extensions::Common::IsValidActorId;
|
||||||
|
|||||||
@ -36,6 +36,8 @@ class RemotePlayer {
|
|||||||
bool IsSpawned() const { return m_spawned; }
|
bool IsSpawned() const { return m_spawned; }
|
||||||
bool IsVisible() const { return m_visible; }
|
bool IsVisible() const { return m_visible; }
|
||||||
int8_t GetWorldId() const { return m_targetWorldId; }
|
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; }
|
uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; }
|
||||||
void SetVisible(bool p_visible);
|
void SetVisible(bool p_visible);
|
||||||
void TriggerEmote(uint8_t p_emoteId);
|
void TriggerEmote(uint8_t p_emoteId);
|
||||||
@ -48,9 +50,13 @@ class RemotePlayer {
|
|||||||
void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); }
|
void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); }
|
||||||
void StopClickAnimation();
|
void StopClickAnimation();
|
||||||
bool IsInVehicle() const { return m_animator.IsInVehicle(); }
|
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 IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; }
|
||||||
bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); }
|
bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); }
|
||||||
|
|
||||||
|
void SetAnimationLocked(bool p_locked) { m_animationLocked = p_locked; }
|
||||||
|
bool IsAnimationLocked() const { return m_animationLocked; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const char* GetDisplayActorName() const;
|
const char* GetDisplayActorName() const;
|
||||||
void UpdateTransform(float p_deltaTime);
|
void UpdateTransform(float p_deltaTime);
|
||||||
@ -76,6 +82,7 @@ class RemotePlayer {
|
|||||||
int8_t m_targetWorldId;
|
int8_t m_targetWorldId;
|
||||||
uint32_t m_lastUpdateTime;
|
uint32_t m_lastUpdateTime;
|
||||||
bool m_hasReceivedUpdate;
|
bool m_hasReceivedUpdate;
|
||||||
|
int16_t m_nearestLocation;
|
||||||
|
|
||||||
float m_currentPosition[3];
|
float m_currentPosition[3];
|
||||||
float m_currentDirection[3];
|
float m_currentDirection[3];
|
||||||
@ -89,6 +96,7 @@ class RemotePlayer {
|
|||||||
|
|
||||||
Extensions::Common::CustomizeState m_customizeState;
|
Extensions::Common::CustomizeState m_customizeState;
|
||||||
bool m_allowRemoteCustomize;
|
bool m_allowRemoteCustomize;
|
||||||
|
bool m_animationLocked;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include <SDL3/SDL_events.h>
|
#include <SDL3/SDL_events.h>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
class IslePathActor;
|
class IslePathActor;
|
||||||
class LegoNavController;
|
class LegoNavController;
|
||||||
@ -56,6 +57,23 @@ class Controller {
|
|||||||
void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); }
|
void SetClickAnimObjectId(MxU32 p_clickAnimObjectId) { m_animator.SetClickAnimObjectId(p_clickAnimObjectId); }
|
||||||
void StopClickAnimation();
|
void StopClickAnimation();
|
||||||
bool IsInVehicle() const { return m_animator.IsInVehicle(); }
|
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<void()> 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 OnWorldEnabled(LegoWorld* p_world);
|
||||||
void OnWorldDisabled(LegoWorld* p_world);
|
void OnWorldDisabled(LegoWorld* p_world);
|
||||||
@ -119,6 +137,9 @@ class Controller {
|
|||||||
bool m_enabled;
|
bool m_enabled;
|
||||||
bool m_active;
|
bool m_active;
|
||||||
bool m_pendingWorldTransition;
|
bool m_pendingWorldTransition;
|
||||||
|
bool m_animPlaying;
|
||||||
|
bool m_animLockDisplay;
|
||||||
|
std::function<void()> m_animStopCallback;
|
||||||
bool m_lmbForwardEngaged;
|
bool m_lmbForwardEngaged;
|
||||||
LegoROI* m_playerROI;
|
LegoROI* m_playerROI;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include "misc/legotree.h"
|
#include "misc/legotree.h"
|
||||||
#include "roi/legoroi.h"
|
#include "roi/legoroi.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@ -14,12 +15,33 @@ using namespace Extensions::Common;
|
|||||||
|
|
||||||
// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime
|
// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime
|
||||||
// via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes.
|
// 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(
|
static void AssignROIIndices(
|
||||||
LegoTreeNode* p_node,
|
LegoTreeNode* p_node,
|
||||||
LegoROI* p_parentROI,
|
LegoROI* p_parentROI,
|
||||||
LegoROI* p_rootROI,
|
LegoROI* p_rootROI,
|
||||||
LegoROI** p_extraROIs,
|
LegoROI** p_extraROIs,
|
||||||
int p_extraROICount,
|
int p_extraROICount,
|
||||||
|
const AnimUtils::ROIAlias* p_aliases,
|
||||||
|
int p_aliasCount,
|
||||||
MxU32& p_nextIndex,
|
MxU32& p_nextIndex,
|
||||||
std::vector<LegoROI*>& p_entries,
|
std::vector<LegoROI*>& p_entries,
|
||||||
bool& p_rootClaimed
|
bool& p_rootClaimed
|
||||||
@ -34,27 +56,50 @@ static void AssignROIIndices(
|
|||||||
|
|
||||||
if (*name == '*' || p_parentROI == nullptr) {
|
if (*name == '*' || p_parentROI == nullptr) {
|
||||||
roi = p_rootROI;
|
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;
|
p_rootClaimed = true;
|
||||||
}
|
}
|
||||||
else if (*name == '*' && p_extraROICount > 0) {
|
|
||||||
// Subsequent *-prefixed node: search extra ROIs by stripped name.
|
// Then check extra ROIs by name.
|
||||||
// FindChildROI checks self first, then children recursively.
|
// This handles cases like BIKESY appearing before SY in the tree:
|
||||||
const char* stripped = name + 1;
|
// BIKESY should match the vehicle extra, not claim the root.
|
||||||
|
if (!matchedExtra && p_extraROICount > 0) {
|
||||||
for (int e = 0; e < p_extraROICount; e++) {
|
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) {
|
if (matchedROI != nullptr) {
|
||||||
|
roi = matchedROI;
|
||||||
|
matchedExtra = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!matchedExtra) {
|
||||||
|
if (!p_rootClaimed) {
|
||||||
|
matchedROI = p_rootROI;
|
||||||
|
p_rootClaimed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
|
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
|
||||||
if (matchedROI == nullptr) {
|
if (matchedROI == nullptr) {
|
||||||
// FindChildROI checks self first, so this handles both
|
// Check aliases — also update roi so children resolve against the alias ROI
|
||||||
// direct name matches and child searches on extra ROIs.
|
matchedROI = FindAlias(name, p_aliases, p_aliasCount);
|
||||||
|
if (matchedROI) {
|
||||||
|
roi = matchedROI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedROI == nullptr) {
|
||||||
for (int e = 0; e < p_extraROICount; e++) {
|
for (int e = 0; e < p_extraROICount; e++) {
|
||||||
matchedROI = p_extraROIs[e]->FindChildROI(name, p_extraROIs[e]);
|
matchedROI = p_extraROIs[e]->FindChildROI(name, p_extraROIs[e]);
|
||||||
if (matchedROI != nullptr) {
|
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) {
|
if (matchedROI != nullptr) {
|
||||||
@ -81,6 +156,8 @@ static void AssignROIIndices(
|
|||||||
p_rootROI,
|
p_rootROI,
|
||||||
p_extraROIs,
|
p_extraROIs,
|
||||||
p_extraROICount,
|
p_extraROICount,
|
||||||
|
p_aliases,
|
||||||
|
p_aliasCount,
|
||||||
p_nextIndex,
|
p_nextIndex,
|
||||||
p_entries,
|
p_entries,
|
||||||
p_rootClaimed
|
p_rootClaimed
|
||||||
@ -94,7 +171,9 @@ void AnimUtils::BuildROIMap(
|
|||||||
LegoROI** p_extraROIs,
|
LegoROI** p_extraROIs,
|
||||||
int p_extraROICount,
|
int p_extraROICount,
|
||||||
LegoROI**& p_roiMap,
|
LegoROI**& p_roiMap,
|
||||||
MxU32& p_roiMapSize
|
MxU32& p_roiMapSize,
|
||||||
|
const ROIAlias* p_aliases,
|
||||||
|
int p_aliasCount
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!p_anim || !p_rootROI) {
|
if (!p_anim || !p_rootROI) {
|
||||||
@ -109,7 +188,7 @@ void AnimUtils::BuildROIMap(
|
|||||||
MxU32 nextIndex = 1;
|
MxU32 nextIndex = 1;
|
||||||
std::vector<LegoROI*> entries;
|
std::vector<LegoROI*> entries;
|
||||||
bool rootClaimed = false;
|
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()) {
|
if (entries.empty()) {
|
||||||
return;
|
return;
|
||||||
@ -223,3 +302,43 @@ void AnimUtils::CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std:
|
|||||||
bool rootClaimed = false;
|
bool rootClaimed = false;
|
||||||
CollectUnmatchedNodesRecursive(root, nullptr, p_rootROI, p_unmatchedNames, rootClaimed);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
float timeInCycle = m_animTime - duration * SDL_floorf(m_animTime / duration);
|
||||||
|
|
||||||
MxMatrix transform(p_roi->GetLocal2World());
|
MxMatrix transform(p_roi->GetLocal2World());
|
||||||
LegoTreeNode* root = walkAnim->GetRoot();
|
AnimUtils::ApplyTree(walkAnim, transform, (LegoTime) timeInCycle, walkRoiMap);
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
|
||||||
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, walkRoiMap);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m_wasMoving = true;
|
m_wasMoving = true;
|
||||||
m_idleTime = 0.0f;
|
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;
|
m_emotePropGroup.roiMap != nullptr ? m_emotePropGroup.roiMap : m_emoteAnimCache->roiMap;
|
||||||
MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World());
|
MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World());
|
||||||
|
|
||||||
LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot();
|
AnimUtils::ApplyTree(m_emoteAnimCache->anim, transform, (LegoTime) m_emoteTime, emoteRoiMap);
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
|
||||||
LegoROI::ApplyAnimationTransformation(
|
|
||||||
root->GetChild(i),
|
|
||||||
transform,
|
|
||||||
(LegoTime) m_emoteTime,
|
|
||||||
emoteRoiMap
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore player ROI transform (animation root overwrote it).
|
// Restore player ROI transform (animation root overwrote it).
|
||||||
if (m_config.saveEmoteTransform) {
|
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
|
// Frozen at last frame of a multi-part emote's phase-1 animation
|
||||||
MxMatrix transform(m_config.saveEmoteTransform ? m_frozenParentTransform : p_roi->GetLocal2World());
|
MxMatrix transform(m_config.saveEmoteTransform ? m_frozenParentTransform : p_roi->GetLocal2World());
|
||||||
|
|
||||||
LegoTreeNode* root = m_frozenAnimCache->anim->GetRoot();
|
AnimUtils::ApplyTree(
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
m_frozenAnimCache->anim,
|
||||||
LegoROI::ApplyAnimationTransformation(
|
transform,
|
||||||
root->GetChild(i),
|
(LegoTime) m_frozenAnimDuration,
|
||||||
transform,
|
m_frozenAnimCache->roiMap
|
||||||
(LegoTime) m_frozenAnimDuration,
|
);
|
||||||
m_frozenAnimCache->roiMap
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_config.saveEmoteTransform) {
|
if (m_config.saveEmoteTransform) {
|
||||||
p_roi->WrappedSetLocal2WorldWithWorldDataUpdate(m_frozenParentTransform);
|
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);
|
float timeInCycle = m_idleAnimTime - duration * SDL_floorf(m_idleAnimTime / duration);
|
||||||
|
|
||||||
MxMatrix transform(p_roi->GetLocal2World());
|
MxMatrix transform(p_roi->GetLocal2World());
|
||||||
LegoTreeNode* root = m_idleAnimCache->anim->GetRoot();
|
AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) timeInCycle, m_idleAnimCache->roiMap);
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
|
||||||
LegoROI::ApplyAnimationTransformation(
|
|
||||||
root->GetChild(i),
|
|
||||||
transform,
|
|
||||||
(LegoTime) timeInCycle,
|
|
||||||
m_idleAnimCache->roiMap
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -448,24 +426,6 @@ void CharacterAnimator::ClearPropGroup(PropGroup& p_group)
|
|||||||
p_group.anim = nullptr;
|
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)
|
void CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, LegoROI* p_playerROI)
|
||||||
{
|
{
|
||||||
std::vector<std::string> unmatchedNames;
|
std::vector<std::string> 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());
|
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);
|
LegoROI* propROI = CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE);
|
||||||
if (propROI) {
|
if (propROI) {
|
||||||
propROI->SetName(name.c_str());
|
propROI->SetName(name.c_str());
|
||||||
@ -545,8 +505,5 @@ void CharacterAnimator::ApplyIdleFrame0(LegoROI* p_roi)
|
|||||||
}
|
}
|
||||||
|
|
||||||
MxMatrix transform(p_roi->GetLocal2World());
|
MxMatrix transform(p_roi->GetLocal2World());
|
||||||
LegoTreeNode* root = m_idleAnimCache->anim->GetRoot();
|
AnimUtils::ApplyTree(m_idleAnimCache->anim, transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap);
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
|
||||||
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
#include "extensions/common/animdata.h"
|
#include "extensions/common/charactertables.h"
|
||||||
|
|
||||||
#include "legopathactor.h"
|
#include "legopathactor.h"
|
||||||
|
|
||||||
24
extensions/src/common/pathutils.cpp
Normal file
24
extensions/src/common/pathutils.cpp
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#include "extensions/common/pathutils.h"
|
||||||
|
|
||||||
|
#include "legomain.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_filesystem.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
50
extensions/src/multiplayer/animation/audioplayer.cpp
Normal file
50
extensions/src/multiplayer/animation/audioplayer.cpp
Normal file
@ -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<SceneAnimData::AudioTrack>& p_tracks)
|
||||||
|
{
|
||||||
|
for (const auto& audioTrack : p_tracks) {
|
||||||
|
LegoCacheSound* sound = new LegoCacheSound();
|
||||||
|
MxString mediaSrcPath(audioTrack.mediaSrcPath.c_str());
|
||||||
|
MxWavePresenter::WaveFormat format = audioTrack.format;
|
||||||
|
if (sound->Create(format, mediaSrcPath, audioTrack.volume, audioTrack.pcmData, audioTrack.pcmDataSize) ==
|
||||||
|
SUCCESS) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
228
extensions/src/multiplayer/animation/catalog.cpp
Normal file
228
extensions/src/multiplayer/animation/catalog.cpp
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
#include "extensions/multiplayer/animation/catalog.h"
|
||||||
|
|
||||||
|
#include "decomp.h"
|
||||||
|
#include "legoanimationmanager.h"
|
||||||
|
#include "legocharactermanager.h"
|
||||||
|
#include "misc.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_stdinc.h>
|
||||||
|
|
||||||
|
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<int8_t> Multiplayer::Animation::GetPerformerIndices(uint64_t p_performerMask)
|
||||||
|
{
|
||||||
|
std::vector<int8_t> indices;
|
||||||
|
for (int8_t i = 0; i < 64; i++) {
|
||||||
|
if (p_performerMask & (uint64_t(1) << i)) {
|
||||||
|
indices.push_back(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<const CatalogEntry*> Catalog::GetAnimationsAtLocation(int16_t p_location) const
|
||||||
|
{
|
||||||
|
std::vector<const CatalogEntry*> result;
|
||||||
|
|
||||||
|
// Helper to add entries from a location, filtering out e_otherAnim
|
||||||
|
auto addFromLocation = [&](int16_t loc) {
|
||||||
|
auto it = m_locationIndex.find(loc);
|
||||||
|
if (it != m_locationIndex.end()) {
|
||||||
|
for (size_t idx : it->second) {
|
||||||
|
if (m_entries[idx].category != e_otherAnim) {
|
||||||
|
result.push_back(&m_entries[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always include NPC animations (location == -1)
|
||||||
|
addFromLocation(-1);
|
||||||
|
|
||||||
|
// If requesting a specific location, also include location-bound animations
|
||||||
|
if (p_location >= 0) {
|
||||||
|
addFromLocation(p_location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Catalog::CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex)
|
||||||
|
{
|
||||||
|
if (p_charIndex < CORE_CHARACTER_COUNT) {
|
||||||
|
return (p_entry->spectatorMask >> p_charIndex) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-core characters (index 5+): only if all core actors allowed
|
||||||
|
return p_entry->spectatorMask == ALL_CORE_ACTORS_MASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Catalog::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<bool> assignedAsPerformer(p_count, false);
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < p_count; i++) {
|
||||||
|
int8_t charIndex = p_charIndices[i];
|
||||||
|
if (charIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t charBit = uint64_t(1) << charIndex;
|
||||||
|
if ((p_entry->performerMask & charBit) && !(*p_filledPerformers & charBit)) {
|
||||||
|
*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;
|
||||||
|
}
|
||||||
271
extensions/src/multiplayer/animation/coordinator.cpp
Normal file
271
extensions/src/multiplayer/animation/coordinator.cpp
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
#include "extensions/multiplayer/animation/coordinator.h"
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/animation/catalog.h"
|
||||||
|
#include "legoanimationmanager.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_timer.h>
|
||||||
|
|
||||||
|
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<SlotInfo>& p_slots
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// One slot per performer bit in performerMask
|
||||||
|
for (int8_t i : GetPerformerIndices(p_entry->performerMask)) {
|
||||||
|
SlotInfo slot;
|
||||||
|
if (i < (int8_t) sizeOfArray(g_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<EligibilityInfo> 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<EligibilityInfo> result;
|
||||||
|
|
||||||
|
if (!m_catalog || p_locationCount == 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto anims = m_catalog->GetAnimationsAtLocation(p_location);
|
||||||
|
|
||||||
|
for (const CatalogEntry* entry : anims) {
|
||||||
|
// p_locationChars[0] == p_proximityChars[0] == local player
|
||||||
|
if (!Catalog::CanParticipateChar(entry, p_locationChars[0])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<uint8_t>(info.slots.size());
|
||||||
|
for (uint8_t s = 0; s < slotCount; s++) {
|
||||||
|
info.slots[s].filled = (sv.peerSlots[s] != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_back(std::move(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Coordinator::OnLocationChanged(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<CoordinationState>(p_state);
|
||||||
|
sv.countdownMs = p_countdownMs;
|
||||||
|
sv.countdownEndTime = (p_countdownMs > 0) ? (SDL_GetTicks() + p_countdownMs) : 0;
|
||||||
|
sv.slotCount = p_slotCount < 8 ? p_slotCount : 8;
|
||||||
|
for (uint8_t i = 0; i < 8; i++) {
|
||||||
|
sv.peerSlots[i] = (i < sv.slotCount) ? p_slots[i] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If local player is in this session, update coordinator state
|
||||||
|
if (m_localPeerId != 0) {
|
||||||
|
bool localInSession = false;
|
||||||
|
for (uint8_t i = 0; i < sv.slotCount; i++) {
|
||||||
|
if (sv.peerSlots[i] == m_localPeerId) {
|
||||||
|
localInSession = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localInSession && !m_cancelPending) {
|
||||||
|
m_currentAnimIndex = p_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;
|
||||||
|
}
|
||||||
442
extensions/src/multiplayer/animation/loader.cpp
Normal file
442
extensions/src/multiplayer/animation/loader.cpp
Normal file
@ -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 <SDL3/SDL_stdinc.h>
|
||||||
|
#include <file.h>
|
||||||
|
#include <interleaf.h>
|
||||||
|
|
||||||
|
using namespace Multiplayer::Animation;
|
||||||
|
|
||||||
|
static void ParseExtraDirectives(const si::bytearray& p_extra, SceneAnimData& p_data)
|
||||||
|
{
|
||||||
|
if (p_extra.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string extra(p_extra.data(), p_extra.size());
|
||||||
|
while (!extra.empty() && extra.back() == '\0') {
|
||||||
|
extra.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.find("HIDE_ON_STOP") != std::string::npos) {
|
||||||
|
p_data.hideOnStop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t pos = extra.find("PTATCAM=");
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
pos += 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<MxU8*>(track.flcHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneAnimData::SceneAnimData(SceneAnimData&& p_other) noexcept
|
||||||
|
: anim(p_other.anim), duration(p_other.duration), audioTracks(std::move(p_other.audioTracks)),
|
||||||
|
phonemeTracks(std::move(p_other.phonemeTracks)), actionTransform(p_other.actionTransform),
|
||||||
|
ptAtCamNames(std::move(p_other.ptAtCamNames)), hideOnStop(p_other.hideOnStop)
|
||||||
|
{
|
||||||
|
p_other.anim = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneAnimData& SceneAnimData::operator=(SceneAnimData&& p_other) noexcept
|
||||||
|
{
|
||||||
|
if (this != &p_other) {
|
||||||
|
delete anim;
|
||||||
|
ReleaseTracks();
|
||||||
|
|
||||||
|
anim = p_other.anim;
|
||||||
|
duration = p_other.duration;
|
||||||
|
audioTracks = std::move(p_other.audioTracks);
|
||||||
|
phonemeTracks = std::move(p_other.phonemeTracks);
|
||||||
|
actionTransform = p_other.actionTransform;
|
||||||
|
ptAtCamNames = std::move(p_other.ptAtCamNames);
|
||||||
|
hideOnStop = p_other.hideOnStop;
|
||||||
|
p_other.anim = nullptr;
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader::Loader()
|
||||||
|
: m_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<si::Object*>(m_interleaf->GetChildAt(p_objectId));
|
||||||
|
if (obj->type() != si::MxOb::Null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_interleaf->ReadObject(m_siFile, p_objectId) == si::Interleaf::ERROR_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FLIC_HEADER*>(headerBuf);
|
||||||
|
track.width = track.flcHeader->width;
|
||||||
|
track.height = track.flcHeader->height;
|
||||||
|
|
||||||
|
for (size_t i = 1; i < chunks.size(); i++) {
|
||||||
|
track.frameData.push_back(chunks[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p_child->extra_.empty()) {
|
||||||
|
track.roiName = std::string(p_child->extra_.data(), p_child->extra_.size());
|
||||||
|
while (!track.roiName.empty() && track.roiName.back() == '\0') {
|
||||||
|
track.roiName.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track.timeOffset = p_child->time_offset_;
|
||||||
|
|
||||||
|
p_data.phonemeTracks.push_back(std::move(track));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Loader::ParseComposite(si::Object* p_composite, SceneAnimData& p_data)
|
||||||
|
{
|
||||||
|
bool hasAnim = false;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < p_composite->GetChildCount(); i++) {
|
||||||
|
si::Object* child = static_cast<si::Object*>(p_composite->GetChildAt(i));
|
||||||
|
|
||||||
|
if (child->presenter_.find("LegoPhonemePresenter") != std::string::npos) {
|
||||||
|
ParsePhonemeChild(child, p_data);
|
||||||
|
}
|
||||||
|
else if (child->presenter_.find("LegoAnimPresenter") != std::string::npos || child->presenter_.find("LegoLoopingAnimPresenter") != std::string::npos) {
|
||||||
|
if (!hasAnim) {
|
||||||
|
if (ParseAnimationChild(child, p_data)) {
|
||||||
|
hasAnim = true;
|
||||||
|
ParseExtraDirectives(child->extra_, p_data);
|
||||||
|
|
||||||
|
// Extract action transform. Try child first, fall back to composite if zero.
|
||||||
|
si::Object* source = child;
|
||||||
|
if (SDL_fabs(child->direction_.x) < 1e-7 && SDL_fabs(child->direction_.y) < 1e-7 &&
|
||||||
|
SDL_fabs(child->direction_.z) < 1e-7) {
|
||||||
|
source = p_composite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p_data.actionTransform.location[0] = (float) source->location_.x;
|
||||||
|
p_data.actionTransform.location[1] = (float) source->location_.y;
|
||||||
|
p_data.actionTransform.location[2] = (float) source->location_.z;
|
||||||
|
p_data.actionTransform.direction[0] = (float) source->direction_.x;
|
||||||
|
p_data.actionTransform.direction[1] = (float) source->direction_.y;
|
||||||
|
p_data.actionTransform.direction[2] = (float) source->direction_.z;
|
||||||
|
p_data.actionTransform.up[0] = (float) source->up_.x;
|
||||||
|
p_data.actionTransform.up[1] = (float) source->up_.y;
|
||||||
|
p_data.actionTransform.up[2] = (float) source->up_.z;
|
||||||
|
|
||||||
|
p_data.actionTransform.valid =
|
||||||
|
(SDL_fabsf(p_data.actionTransform.direction[0]) >= 0.00000047683716f ||
|
||||||
|
SDL_fabsf(p_data.actionTransform.direction[1]) >= 0.00000047683716f ||
|
||||||
|
SDL_fabsf(p_data.actionTransform.direction[2]) >= 0.00000047683716f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (child->filetype() == si::MxOb::WAV) {
|
||||||
|
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<si::Object*>(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<si::Object*>(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();
|
||||||
|
}
|
||||||
60
extensions/src/multiplayer/animation/locationproximity.cpp
Normal file
60
extensions/src/multiplayer/animation/locationproximity.cpp
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
#include "extensions/multiplayer/animation/locationproximity.h"
|
||||||
|
|
||||||
|
#include "decomp.h"
|
||||||
|
#include "legolocations.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
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<int16_t>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestLocation;
|
||||||
|
}
|
||||||
169
extensions/src/multiplayer/animation/phonemeplayer.cpp
Normal file
169
extensions/src/multiplayer/animation/phonemeplayer.cpp
Normal file
@ -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 <SDL3/SDL_stdinc.h>
|
||||||
|
|
||||||
|
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<SceneAnimData::PhonemeTrack>& 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<SceneAnimData::PhonemeTrack>& p_tracks)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < p_tracks.size() && i < m_states.size(); i++) {
|
||||||
|
auto& track = p_tracks[i];
|
||||||
|
auto& state = m_states[i];
|
||||||
|
|
||||||
|
if (!state.bitmap || !state.cachedTexture) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float trackElapsed = p_elapsedMs - (float) track.timeOffset;
|
||||||
|
if (trackElapsed < 0.0f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.flcHeader->speed == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int targetFrame = (int) (trackElapsed / (float) track.flcHeader->speed);
|
||||||
|
if (targetFrame == state.currentFrame) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (targetFrame >= (int) track.frameData.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int startFrame = state.currentFrame + 1;
|
||||||
|
if (startFrame < 0) {
|
||||||
|
startFrame = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int f = startFrame; f <= targetFrame; f++) {
|
||||||
|
const auto& data = track.frameData[f];
|
||||||
|
if (data.size() < sizeof(MxS32)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MxS32 rectCount;
|
||||||
|
SDL_memcpy(&rectCount, data.data(), sizeof(MxS32));
|
||||||
|
size_t headerSize = sizeof(MxS32) + rectCount * sizeof(MxRect32);
|
||||||
|
if (data.size() <= headerSize) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FLIC_FRAME* flcFrame = (FLIC_FRAME*) (data.data() + headerSize);
|
||||||
|
|
||||||
|
BYTE decodedColorMap;
|
||||||
|
DecodeFLCFrame(
|
||||||
|
&state.bitmap->GetBitmapInfo()->m_bmiHeader,
|
||||||
|
state.bitmap->GetImage(),
|
||||||
|
track.flcHeader,
|
||||||
|
flcFrame,
|
||||||
|
&decodedColorMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the FLC frame updates the palette, apply it to the texture surface
|
||||||
|
if (decodedColorMap && state.cachedTexture->m_palette) {
|
||||||
|
PALETTEENTRY entries[256];
|
||||||
|
RGBQUAD* colors = state.bitmap->GetBitmapInfo()->m_bmiColors;
|
||||||
|
for (int c = 0; c < 256; c++) {
|
||||||
|
entries[c].peRed = colors[c].rgbRed;
|
||||||
|
entries[c].peGreen = colors[c].rgbGreen;
|
||||||
|
entries[c].peBlue = colors[c].rgbBlue;
|
||||||
|
entries[c].peFlags = PC_NONE;
|
||||||
|
}
|
||||||
|
state.cachedTexture->m_palette->SetEntries(0, 0, 256, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cachedTexture->LoadBits(state.bitmap->GetImage());
|
||||||
|
state.currentFrame = targetFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhonemePlayer::Cleanup()
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < m_states.size(); i++) {
|
||||||
|
auto& state = m_states[i];
|
||||||
|
|
||||||
|
if (state.targetROI && state.originalTexture) {
|
||||||
|
CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cachedTexture) {
|
||||||
|
TextureContainer()->EraseCached(state.cachedTexture);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete state.bitmap;
|
||||||
|
}
|
||||||
|
m_states.clear();
|
||||||
|
}
|
||||||
575
extensions/src/multiplayer/animation/sceneplayer.cpp
Normal file
575
extensions/src/multiplayer/animation/sceneplayer.cpp
Normal file
@ -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 <SDL3/SDL_stdinc.h>
|
||||||
|
#include <SDL3/SDL_timer.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace Multiplayer::Animation;
|
||||||
|
namespace AnimUtils = Extensions::Common::AnimUtils;
|
||||||
|
using Extensions::Common::CharacterCloner;
|
||||||
|
|
||||||
|
// 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<LegoROI*> createdROIs;
|
||||||
|
std::vector<AnimUtils::ROIAlias> aliases;
|
||||||
|
std::deque<std::string> aliasNames;
|
||||||
|
|
||||||
|
std::vector<bool> 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<LegoROI*> extras;
|
||||||
|
for (size_t p = 0; p < m_participants.size(); p++) {
|
||||||
|
if (m_participants[p].roi != rootROI && participantMatched[p]) {
|
||||||
|
extras.push_back(m_participants[p].roi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto* propROI : m_propROIs) {
|
||||||
|
extras.push_back(propROI);
|
||||||
|
}
|
||||||
|
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<bool(LegoTreeNode*, MxMatrix&)> findOrigin = [&](LegoTreeNode* node, MxMatrix& parentWorld) -> bool {
|
||||||
|
LegoAnimNodeData* data = (LegoAnimNodeData*) node->GetData();
|
||||||
|
MxU32 roiIdx = data ? data->GetROIIndex() : 0;
|
||||||
|
|
||||||
|
MxMatrix localMat;
|
||||||
|
LegoROI::CreateLocalTransform(data, 0, localMat);
|
||||||
|
MxMatrix worldMat;
|
||||||
|
worldMat.Product(localMat, parentWorld);
|
||||||
|
|
||||||
|
if (roiIdx != 0 && m_roiMap[roiIdx] == m_animRootROI) {
|
||||||
|
m_animPose0 = worldMat;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (LegoU32 i = 0; i < node->GetNumChildren(); i++) {
|
||||||
|
if (findOrigin(node->GetChild(i), worldMat)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
MxMatrix identity;
|
||||||
|
identity.SetIdentity();
|
||||||
|
findOrigin(m_currentData->anim->GetRoot(), identity);
|
||||||
|
|
||||||
|
// Inverse of animPose0 (rigid body: transpose rotation, negate translated position)
|
||||||
|
MxMatrix invAnimPose0;
|
||||||
|
invAnimPose0.SetIdentity();
|
||||||
|
for (int r = 0; r < 3; r++) {
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
invAnimPose0[r][c] = m_animPose0[c][r];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int r = 0; r < 3; r++) {
|
||||||
|
invAnimPose0[3][r] =
|
||||||
|
-(invAnimPose0[0][r] * m_animPose0[3][0] + invAnimPose0[1][r] * m_animPose0[3][1] +
|
||||||
|
invAnimPose0[2][r] * m_animPose0[3][2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_rebaseMatrix.Product(invAnimPose0, targetTransform);
|
||||||
|
m_rebaseComputed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScenePlayer::ResolvePtAtCamROIs()
|
||||||
|
{
|
||||||
|
m_ptAtCamROIs.clear();
|
||||||
|
if (!m_currentData || m_currentData->ptAtCamNames.empty() || !m_roiMap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& name : m_currentData->ptAtCamNames) {
|
||||||
|
for (MxU32 i = 1; i < m_roiMapSize; i++) {
|
||||||
|
if (m_roiMap[i] && m_roiMap[i]->GetName() && !SDL_strcasecmp(name.c_str(), m_roiMap[i]->GetName())) {
|
||||||
|
m_ptAtCamROIs.push_back(m_roiMap[i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScenePlayer::ApplyPtAtCam()
|
||||||
|
{
|
||||||
|
if (m_ptAtCamROIs.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (!world || !world->GetCameraController()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same math as LegoAnimPresenter::PutFrame
|
||||||
|
for (LegoROI* roi : m_ptAtCamROIs) {
|
||||||
|
if (!roi) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MxMatrix mat(roi->GetLocal2World());
|
||||||
|
|
||||||
|
Vector3 pos(mat[0]);
|
||||||
|
Vector3 dir(mat[1]);
|
||||||
|
Vector3 up(mat[2]);
|
||||||
|
Vector3 und(mat[3]);
|
||||||
|
|
||||||
|
float possqr = sqrt(pos.LenSquared());
|
||||||
|
float dirsqr = sqrt(dir.LenSquared());
|
||||||
|
float upsqr = sqrt(up.LenSquared());
|
||||||
|
|
||||||
|
up = und;
|
||||||
|
up -= world->GetCameraController()->GetWorldLocation();
|
||||||
|
dir /= dirsqr;
|
||||||
|
pos.EqualsCross(dir, up);
|
||||||
|
pos.Unitize();
|
||||||
|
up.EqualsCross(pos, dir);
|
||||||
|
pos *= possqr;
|
||||||
|
dir *= dirsqr;
|
||||||
|
up *= upsqr;
|
||||||
|
|
||||||
|
roi->SetLocal2World(mat);
|
||||||
|
roi->WrappedUpdateWorldData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScenePlayer::Tick()
|
||||||
|
{
|
||||||
|
if (!m_playing || !m_currentData || m_participants.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_startTime == 0) {
|
||||||
|
m_startTime = SDL_GetTicks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_category == e_npcAnim && m_roiMap) {
|
||||||
|
AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
float elapsed = (float) (SDL_GetTicks() - m_startTime);
|
||||||
|
|
||||||
|
if (elapsed >= m_currentData->duration) {
|
||||||
|
Stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Skeletal animation
|
||||||
|
if (m_currentData->anim && m_roiMap) {
|
||||||
|
if (!m_rebaseComputed) {
|
||||||
|
if (m_category == e_camAnim) {
|
||||||
|
// cam_anims use the action transform directly (keyframes are in world space)
|
||||||
|
if (m_currentData->actionTransform.valid) {
|
||||||
|
Mx3DPointFloat loc(
|
||||||
|
m_currentData->actionTransform.location[0],
|
||||||
|
m_currentData->actionTransform.location[1],
|
||||||
|
m_currentData->actionTransform.location[2]
|
||||||
|
);
|
||||||
|
Mx3DPointFloat dir(
|
||||||
|
m_currentData->actionTransform.direction[0],
|
||||||
|
m_currentData->actionTransform.direction[1],
|
||||||
|
m_currentData->actionTransform.direction[2]
|
||||||
|
);
|
||||||
|
Mx3DPointFloat up(
|
||||||
|
m_currentData->actionTransform.up[0],
|
||||||
|
m_currentData->actionTransform.up[1],
|
||||||
|
m_currentData->actionTransform.up[2]
|
||||||
|
);
|
||||||
|
CalcLocalTransform(loc, dir, up, m_rebaseMatrix);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
m_rebaseMatrix.SetIdentity();
|
||||||
|
}
|
||||||
|
m_rebaseComputed = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ComputeRebaseMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Camera animation (cam_anim only)
|
||||||
|
if (m_hasCamAnim) {
|
||||||
|
MxMatrix camTransform(m_rebaseMatrix);
|
||||||
|
m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform);
|
||||||
|
|
||||||
|
LegoWorld* world = CurrentWorld();
|
||||||
|
if (world && world->GetCameraController()) {
|
||||||
|
world->GetCameraController()->TransformPointOfView(camTransform, FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PTATCAM post-processing
|
||||||
|
ApplyPtAtCam();
|
||||||
|
|
||||||
|
// 4. Audio
|
||||||
|
const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr;
|
||||||
|
m_audioPlayer.Tick(elapsed, audioROIName);
|
||||||
|
|
||||||
|
// 5. Phoneme frames
|
||||||
|
m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScenePlayer::Stop()
|
||||||
|
{
|
||||||
|
if (!m_playing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_audioPlayer.Cleanup();
|
||||||
|
m_phonemePlayer.Cleanup();
|
||||||
|
|
||||||
|
if (m_hideOnStop && m_roiMap) {
|
||||||
|
for (MxU32 i = 1; i < m_roiMapSize; i++) {
|
||||||
|
if (m_roiMap[i]) {
|
||||||
|
m_roiMap[i]->SetVisibility(FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_hiddenVehicleROI) {
|
||||||
|
m_hiddenVehicleROI->SetVisibility(TRUE);
|
||||||
|
m_hiddenVehicleROI = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
CleanupProps();
|
||||||
|
m_vehicleROI = nullptr;
|
||||||
|
|
||||||
|
delete[] m_roiMap;
|
||||||
|
m_roiMap = nullptr;
|
||||||
|
m_roiMapSize = 0;
|
||||||
|
|
||||||
|
for (auto& p : m_participants) {
|
||||||
|
p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform);
|
||||||
|
p.roi->SetVisibility(TRUE);
|
||||||
|
}
|
||||||
|
m_participants.clear();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
310
extensions/src/multiplayer/animation/sessionhost.cpp
Normal file
310
extensions/src/multiplayer/animation/sessionhost.cpp
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
#include "extensions/multiplayer/animation/sessionhost.h"
|
||||||
|
|
||||||
|
#include "extensions/multiplayer/animation/catalog.h"
|
||||||
|
#include "extensions/multiplayer/animation/coordinator.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL_timer.h>
|
||||||
|
|
||||||
|
using namespace Multiplayer::Animation;
|
||||||
|
|
||||||
|
static bool HasAnyFilledSlot(const AnimSession& p_session)
|
||||||
|
{
|
||||||
|
for (const auto& slot : p_session.slots) {
|
||||||
|
if (slot.peerId != 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionHost::SetCatalog(const Catalog* p_catalog)
|
||||||
|
{
|
||||||
|
m_catalog = p_catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimSession SessionHost::CreateSession(const CatalogEntry* p_entry, uint16_t p_animIndex)
|
||||||
|
{
|
||||||
|
AnimSession session;
|
||||||
|
session.animIndex = p_animIndex;
|
||||||
|
session.state = CoordinationState::e_interested;
|
||||||
|
session.countdownEndTime = 0;
|
||||||
|
|
||||||
|
for (int8_t i : GetPerformerIndices(p_entry->performerMask)) {
|
||||||
|
SessionSlot slot;
|
||||||
|
slot.peerId = 0;
|
||||||
|
slot.charIndex = i;
|
||||||
|
session.slots.push_back(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionSlot spectatorSlot;
|
||||||
|
spectatorSlot.peerId = 0;
|
||||||
|
spectatorSlot.charIndex = -1;
|
||||||
|
session.slots.push_back(spectatorSlot);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::TryAssignSlot(AnimSession& p_session, uint32_t p_peerId, int8_t p_charIndex)
|
||||||
|
{
|
||||||
|
for (const auto& slot : p_session.slots) {
|
||||||
|
if (slot.peerId == p_peerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performer slots first
|
||||||
|
for (auto& slot : p_session.slots) {
|
||||||
|
if (!slot.IsSpectator() && slot.peerId == 0 && slot.charIndex == p_charIndex) {
|
||||||
|
slot.peerId = p_peerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spectator slot
|
||||||
|
if (!m_catalog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CatalogEntry* entry = m_catalog->FindEntry(p_session.animIndex);
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& slot : p_session.slots) {
|
||||||
|
if (slot.IsSpectator() && slot.peerId == 0) {
|
||||||
|
if (p_charIndex >= 0 && !((entry->performerMask >> p_charIndex) & 1) &&
|
||||||
|
Catalog::CheckSpectatorMask(entry, p_charIndex)) {
|
||||||
|
slot.peerId = p_peerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::AllSlotsFilled(const AnimSession& p_session) const
|
||||||
|
{
|
||||||
|
for (const auto& slot : p_session.slots) {
|
||||||
|
if (slot.peerId == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionHost::RemovePlayerFromAllSessions(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
|
||||||
|
{
|
||||||
|
RemovePlayerFromSessions(p_peerId, false, p_changedAnims);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionHost::RemovePlayerFromSessions(
|
||||||
|
uint32_t p_peerId,
|
||||||
|
bool p_includePlayingSessions,
|
||||||
|
std::vector<uint16_t>& p_changedAnims
|
||||||
|
)
|
||||||
|
{
|
||||||
|
std::vector<uint16_t> toErase;
|
||||||
|
|
||||||
|
for (auto& [animIndex, session] : m_sessions) {
|
||||||
|
if (!p_includePlayingSessions && session.state == CoordinationState::e_playing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool found = false;
|
||||||
|
for (auto& slot : session.slots) {
|
||||||
|
if (slot.peerId == p_peerId) {
|
||||||
|
slot.peerId = 0;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
if (session.state == CoordinationState::e_countdown) {
|
||||||
|
session.state = CoordinationState::e_interested;
|
||||||
|
session.countdownEndTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasAnyFilledSlot(session)) {
|
||||||
|
toErase.push_back(animIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
p_changedAnims.push_back(animIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint16_t idx : toErase) {
|
||||||
|
m_sessions.erase(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::HandleInterest(
|
||||||
|
uint32_t p_peerId,
|
||||||
|
uint16_t p_animIndex,
|
||||||
|
uint8_t p_displayActorIndex,
|
||||||
|
std::vector<uint16_t>& p_changedAnims
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!m_catalog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int8_t charIndex = Catalog::DisplayActorToCharacterIndex(p_displayActorIndex);
|
||||||
|
|
||||||
|
RemovePlayerFromAllSessions(p_peerId, p_changedAnims);
|
||||||
|
|
||||||
|
const CatalogEntry* entry = m_catalog->FindEntry(p_animIndex);
|
||||||
|
if (!entry) {
|
||||||
|
return !p_changedAnims.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto it = m_sessions.find(p_animIndex);
|
||||||
|
if (it == m_sessions.end()) {
|
||||||
|
m_sessions[p_animIndex] = CreateSession(entry, p_animIndex);
|
||||||
|
it = m_sessions.find(p_animIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool assigned = TryAssignSlot(it->second, p_peerId, charIndex);
|
||||||
|
|
||||||
|
// Always broadcast: on success the new slot is shown, on failure the rejected
|
||||||
|
// player's client receives the session state and clears their optimistic interest.
|
||||||
|
p_changedAnims.push_back(p_animIndex);
|
||||||
|
|
||||||
|
// Clean up empty sessions (created but no one could fill a slot)
|
||||||
|
if (!assigned) {
|
||||||
|
if (!HasAnyFilledSlot(it->second)) {
|
||||||
|
m_sessions.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !p_changedAnims.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::HandleCancel(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
|
||||||
|
{
|
||||||
|
RemovePlayerFromSessions(p_peerId, true, p_changedAnims);
|
||||||
|
|
||||||
|
// Explicit cancel during playback: erase entire session so all participants stop
|
||||||
|
for (uint16_t animIndex : p_changedAnims) {
|
||||||
|
auto it = m_sessions.find(animIndex);
|
||||||
|
if (it != m_sessions.end() && it->second.state == CoordinationState::e_playing) {
|
||||||
|
m_sessions.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !p_changedAnims.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::HandlePlayerRemoved(uint32_t p_peerId, std::vector<uint16_t>& p_changedAnims)
|
||||||
|
{
|
||||||
|
RemovePlayerFromSessions(p_peerId, true, p_changedAnims);
|
||||||
|
return !p_changedAnims.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionHost::StartCountdown(uint16_t p_animIndex)
|
||||||
|
{
|
||||||
|
auto it = m_sessions.find(p_animIndex);
|
||||||
|
if (it != m_sessions.end() && it->second.state == CoordinationState::e_interested) {
|
||||||
|
it->second.state = CoordinationState::e_countdown;
|
||||||
|
it->second.countdownEndTime = SDL_GetTicks() + COUNTDOWN_DURATION_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionHost::RevertCountdown(uint16_t p_animIndex)
|
||||||
|
{
|
||||||
|
auto it = m_sessions.find(p_animIndex);
|
||||||
|
if (it != m_sessions.end() && it->second.state == CoordinationState::e_countdown) {
|
||||||
|
it->second.state = CoordinationState::e_interested;
|
||||||
|
it->second.countdownEndTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<uint16_t, AnimSession>& SessionHost::GetSessions() const
|
||||||
|
{
|
||||||
|
return m_sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::AreAllSlotsFilled(uint16_t p_animIndex) const
|
||||||
|
{
|
||||||
|
auto it = m_sessions.find(p_animIndex);
|
||||||
|
if (it == m_sessions.end()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return AllSlotsFilled(it->second);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t SessionHost::ComputeCountdownMs(const AnimSession& p_session, uint32_t p_now)
|
||||||
|
{
|
||||||
|
if (p_session.state != CoordinationState::e_countdown) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_now >= p_session.countdownEndTime) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t remaining = p_session.countdownEndTime - p_now;
|
||||||
|
if (remaining > 0xFFFF) {
|
||||||
|
return 0xFFFF;
|
||||||
|
}
|
||||||
|
return static_cast<uint16_t>(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionHost::HasCountdownSession() const
|
||||||
|
{
|
||||||
|
for (const auto& [animIndex, session] : m_sessions) {
|
||||||
|
if (session.state == CoordinationState::e_countdown) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int8_t> SessionHost::ComputeSlotCharIndices(const CatalogEntry* p_entry)
|
||||||
|
{
|
||||||
|
std::vector<int8_t> indices;
|
||||||
|
if (!p_entry) {
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performers: one slot per set bit in performerMask (same order as CreateSession)
|
||||||
|
indices = GetPerformerIndices(p_entry->performerMask);
|
||||||
|
|
||||||
|
// Spectator slot last
|
||||||
|
indices.push_back(-1);
|
||||||
|
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,20 @@ void EmscriptenCallbacks::OnConnectionStatusChanged(int p_status)
|
|||||||
// clang-format on
|
// 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
|
} // namespace Multiplayer
|
||||||
|
|
||||||
#endif // __EMSCRIPTEN__
|
#endif // __EMSCRIPTEN__
|
||||||
|
|||||||
@ -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<int32_t>(animIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE void mp_cancel_anim_interest()
|
||||||
|
{
|
||||||
|
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
|
||||||
|
if (mgr) {
|
||||||
|
mgr->RequestCancelAnimInterest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -52,6 +52,11 @@ void NativeCallbacks::OnConnectionStatusChanged(int p_status)
|
|||||||
SDL_Log("[Multiplayer] Connection status: %s", statusStr);
|
SDL_Log("[Multiplayer] Connection status: %s", statusStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void NativeCallbacks::OnAnimationsAvailable(const char* p_json)
|
||||||
|
{
|
||||||
|
(void) p_json;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|
||||||
#endif // !__EMSCRIPTEN__
|
#endif // !__EMSCRIPTEN__
|
||||||
|
|||||||
@ -29,9 +29,9 @@ using Common::WORLD_NOT_VISIBLE;
|
|||||||
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
|
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_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_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_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';
|
m_displayName[0] = '\0';
|
||||||
const char* displayName = GetDisplayActorName();
|
const char* displayName = GetDisplayActorName();
|
||||||
@ -197,6 +197,15 @@ void RemotePlayer::Tick(float p_deltaTime)
|
|||||||
return;
|
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();
|
UpdateVehicleState();
|
||||||
UpdateTransform(p_deltaTime);
|
UpdateTransform(p_deltaTime);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "extensions/siloader.h"
|
#include "extensions/siloader.h"
|
||||||
|
|
||||||
|
#include "extensions/common/pathutils.h"
|
||||||
#include "legovideomanager.h"
|
#include "legovideomanager.h"
|
||||||
#include "misc.h"
|
#include "misc.h"
|
||||||
#include "mxdsaction.h"
|
#include "mxdsaction.h"
|
||||||
@ -240,15 +241,11 @@ bool SiLoaderExt::LoadFile(const char* p_file)
|
|||||||
si::Interleaf si;
|
si::Interleaf si;
|
||||||
MxStreamController* controller;
|
MxStreamController* controller;
|
||||||
|
|
||||||
MxString path = MxString(MxOmni::GetHD()) + p_file;
|
MxString path;
|
||||||
path.MapPathToFilesystem();
|
if (!Common::ResolveGamePath(p_file, path) ||
|
||||||
if (si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) {
|
si.Read(path.GetData(), si::Interleaf::ObjectsOnly) != si::Interleaf::ERROR_SUCCESS) {
|
||||||
path = MxString(MxOmni::GetCD()) + p_file;
|
SDL_Log("Could not parse SI file %s", p_file);
|
||||||
path.MapPathToFilesystem();
|
return false;
|
||||||
if (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))) {
|
if (!(controller = OpenStream(p_file))) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "extensions/textureloader.h"
|
#include "extensions/textureloader.h"
|
||||||
|
|
||||||
|
#include "extensions/common/pathutils.h"
|
||||||
#include "legovideomanager.h"
|
#include "legovideomanager.h"
|
||||||
#include "misc.h"
|
#include "misc.h"
|
||||||
#include "mxdirectx/mxdirect3d.h"
|
#include "mxdirectx/mxdirect3d.h"
|
||||||
@ -115,16 +116,13 @@ SDL_Surface* TextureLoaderExt::FindTexture(const char* p_name)
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_Surface* surface;
|
|
||||||
const char* texturePath = options["texture loader:texture path"].c_str();
|
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();
|
MxString path;
|
||||||
if (!(surface = SDL_LoadBMP(path.GetData()))) {
|
if (!Common::ResolveGamePath(relativePath.GetData(), path)) {
|
||||||
path = MxString(MxOmni::GetCD()) + texturePath + "/" + p_name + ".bmp";
|
return nullptr;
|
||||||
path.MapPathToFilesystem();
|
|
||||||
surface = SDL_LoadBMP(path.GetData());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return surface;
|
return SDL_LoadBMP(path.GetData());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ void ThirdPersonCameraExt::OnSDLEvent(SDL_Event* p_event)
|
|||||||
s_camera->SetLmbForwardEngaged(false);
|
s_camera->SetLmbForwardEngaged(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s_camera->ConsumeAutoDisable()) {
|
if (s_camera->ConsumeAutoDisable() && !s_camera->IsAnimPlaying()) {
|
||||||
s_camera->Disable(/*p_preserveTouch=*/true);
|
s_camera->Disable(/*p_preserveTouch=*/true);
|
||||||
if (s_camera->IsLeftButtonHeld()) {
|
if (s_camera->IsLeftButtonHeld()) {
|
||||||
s_camera->SetLmbForwardEngaged(true);
|
s_camera->SetLmbForwardEngaged(true);
|
||||||
|
|||||||
@ -30,7 +30,9 @@ using namespace Extensions::ThirdPersonCamera;
|
|||||||
|
|
||||||
Controller::Controller()
|
Controller::Controller()
|
||||||
: m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true, /*.propSuffix=*/0}), m_enabled(false),
|
: 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()
|
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) {
|
if (m_active && m_playerROI) {
|
||||||
m_playerROI->SetVisibility(FALSE);
|
m_playerROI->SetVisibility(FALSE);
|
||||||
VideoManager()->Get3DManager()->Remove(*m_playerROI);
|
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();
|
m_orbit.ApplyOrbitCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small vehicle with ride animation
|
// Small vehicle with ride animation (skip when external animation is active —
|
||||||
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) {
|
// the animation controller handles positioning the player and vehicle ROI)
|
||||||
|
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE && !m_animPlaying) {
|
||||||
m_animator.StopClickAnimation();
|
m_animator.StopClickAnimation();
|
||||||
if (m_animator.GetRideAnim() && m_animator.GetRideRoiMap()) {
|
if (m_animator.GetRideAnim() && m_animator.GetRideRoiMap()) {
|
||||||
LegoPathActor* actor = UserActor();
|
LegoPathActor* actor = UserActor();
|
||||||
@ -232,15 +244,7 @@ void Controller::Tick(float p_deltaTime)
|
|||||||
float timeInCycle =
|
float timeInCycle =
|
||||||
m_animator.GetAnimTime() - duration * SDL_floorf(m_animator.GetAnimTime() / duration);
|
m_animator.GetAnimTime() - duration * SDL_floorf(m_animator.GetAnimTime() / duration);
|
||||||
|
|
||||||
LegoTreeNode* root = m_animator.GetRideAnim()->GetRoot();
|
AnimUtils::ApplyTree(m_animator.GetRideAnim(), transform, (LegoTime) timeInCycle, m_animator.GetRideRoiMap());
|
||||||
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
|
|
||||||
LegoROI::ApplyAnimationTransformation(
|
|
||||||
root->GetChild(i),
|
|
||||||
transform,
|
|
||||||
(LegoTime) timeInCycle,
|
|
||||||
m_animator.GetRideRoiMap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -251,6 +255,17 @@ void Controller::Tick(float p_deltaTime)
|
|||||||
return;
|
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
|
// Sync display clone position from native ROI
|
||||||
if (m_display.GetDisplayROI() && m_display.GetDisplayROI() == m_playerROI) {
|
if (m_display.GetDisplayROI() && m_display.GetDisplayROI() == m_playerROI) {
|
||||||
m_display.SyncTransformFromNative(userActor->GetROI());
|
m_display.SyncTransformFromNative(userActor->GetROI());
|
||||||
@ -340,6 +355,16 @@ void Controller::OnWorldDisabled(LegoWorld* p_world)
|
|||||||
if (!p_world) {
|
if (!p_world) {
|
||||||
return;
|
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_active = false;
|
||||||
m_pendingWorldTransition = false;
|
m_pendingWorldTransition = false;
|
||||||
m_playerROI = nullptr;
|
m_playerROI = nullptr;
|
||||||
@ -366,7 +391,7 @@ MxBool Controller::HandleCameraRelativeMovement(
|
|||||||
p_newPos,
|
p_newPos,
|
||||||
p_newDir,
|
p_newDir,
|
||||||
p_deltaTime,
|
p_deltaTime,
|
||||||
m_animator.IsInMultiPartEmote(),
|
m_animator.IsInMultiPartEmote() || m_animPlaying,
|
||||||
m_input.IsLeftButtonHeld()
|
m_input.IsLeftButtonHeld()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user