From 32fd90d8046953216c13e6130c8554c8ccc01f7a Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Fri, 27 Mar 2026 10:42:14 -0700 Subject: [PATCH] Support multiple overlapping locations for scene animation eligibility Players near multiple location points now see cam animations from all overlapping locations instead of only the nearest one. Location proximity radius reduced from 15 to 5 units. NPC animations unchanged (still proximity-based). JSON output updated from "location" to "locations" array. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../multiplayer/animation/coordinator.h | 4 +- .../multiplayer/animation/locationproximity.h | 16 +-- .../extensions/multiplayer/networkmanager.h | 2 +- .../extensions/multiplayer/remoteplayer.h | 8 +- .../src/multiplayer/animation/coordinator.cpp | 25 +++- .../animation/locationproximity.cpp | 40 +++--- extensions/src/multiplayer/networkmanager.cpp | 119 ++++++++++++------ extensions/src/multiplayer/remoteplayer.cpp | 8 +- 8 files changed, 138 insertions(+), 84 deletions(-) diff --git a/extensions/include/extensions/multiplayer/animation/coordinator.h b/extensions/include/extensions/multiplayer/animation/coordinator.h index ffd1b5c5..388b2e51 100644 --- a/extensions/include/extensions/multiplayer/animation/coordinator.h +++ b/extensions/include/extensions/multiplayer/animation/coordinator.h @@ -65,8 +65,8 @@ class Coordinator { 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); + // Auto-clear interest if current animation is not available at any of the new locations. + void OnLocationChanged(const std::vector& p_locations, const Catalog* p_catalog); void Reset(); diff --git a/extensions/include/extensions/multiplayer/animation/locationproximity.h b/extensions/include/extensions/multiplayer/animation/locationproximity.h index 32b1a357..e45fdd5b 100644 --- a/extensions/include/extensions/multiplayer/animation/locationproximity.h +++ b/extensions/include/extensions/multiplayer/animation/locationproximity.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace Multiplayer::Animation { @@ -11,23 +12,22 @@ class LocationProximity { public: LocationProximity(); - // Returns true if nearest location changed since last call + // Returns true if location set 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; } + // All locations within radius (sorted by index for stable comparison) + const std::vector& GetLocations() const { return m_locations; } + bool IsAtLocation(int16_t p_location) const; - 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); + // Static version returning all locations within radius (sorted by index) + static std::vector ComputeAll(float p_x, float p_z, float p_radius); private: - int16_t m_nearestLocation; - float m_nearestDistance; float m_radius; + std::vector m_locations; }; } // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 523a065a..6a764b11 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -142,7 +142,7 @@ class NetworkManager : public MxCore { void BroadcastAnimStart(uint16_t p_animIndex); void BroadcastAnimComplete(uint16_t p_animIndex); void HandleAnimComplete(const AnimCompleteMsg& p_msg); - int16_t GetPeerLocation(uint32_t p_peerId) const; + bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const; bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; bool ValidateSessionLocations(uint16_t p_animIndex); diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index d364072a..d0817a84 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -7,6 +7,7 @@ #include #include +#include class LegoROI; class LegoWorld; @@ -36,8 +37,9 @@ class RemotePlayer { bool IsSpawned() const { return m_spawned; } bool IsVisible() const { return m_visible; } int8_t GetWorldId() const { return m_targetWorldId; } - int16_t GetNearestLocation() const { return m_nearestLocation; } - void SetNearestLocation(int16_t p_location) { m_nearestLocation = p_location; } + const std::vector& GetLocations() const { return m_locations; } + void SetLocations(std::vector p_locations) { m_locations = std::move(p_locations); } + bool IsAtLocation(int16_t p_location) const; uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } void SetVisible(bool p_visible); void TriggerEmote(uint8_t p_emoteId); @@ -84,7 +86,7 @@ class RemotePlayer { int8_t m_targetWorldId; uint32_t m_lastUpdateTime; bool m_hasReceivedUpdate; - int16_t m_nearestLocation; + std::vector m_locations; float m_currentPosition[3]; float m_currentDirection[3]; diff --git a/extensions/src/multiplayer/animation/coordinator.cpp b/extensions/src/multiplayer/animation/coordinator.cpp index 2bbe34fc..5c0a5316 100644 --- a/extensions/src/multiplayer/animation/coordinator.cpp +++ b/extensions/src/multiplayer/animation/coordinator.cpp @@ -145,20 +145,33 @@ std::vector Coordinator::ComputeEligibility( return result; } -void Coordinator::OnLocationChanged(int16_t p_location, const Catalog* p_catalog) +void Coordinator::OnLocationChanged(const std::vector& p_locations, 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 + // Check if the currently interested animation is still available at any of the locations + for (int16_t loc : p_locations) { + auto anims = p_catalog->GetAnimationsAtLocation(loc); + for (const auto* e : anims) { + if (e->animIndex == m_currentAnimIndex) { + return; // still available at this location + } } } - // Animation not at new location — clear interest + // Also check NPC anims when at no location + if (p_locations.empty()) { + auto anims = p_catalog->GetAnimationsAtLocation(-1); + for (const auto* e : anims) { + if (e->animIndex == m_currentAnimIndex) { + return; + } + } + } + + // Animation not at any current location — clear interest m_state = CoordinationState::e_idle; m_currentAnimIndex = ANIM_INDEX_NONE; m_cancelPending = true; diff --git a/extensions/src/multiplayer/animation/locationproximity.cpp b/extensions/src/multiplayer/animation/locationproximity.cpp index b5e126bd..41fd83c4 100644 --- a/extensions/src/multiplayer/animation/locationproximity.cpp +++ b/extensions/src/multiplayer/animation/locationproximity.cpp @@ -3,58 +3,52 @@ #include "decomp.h" #include "legolocations.h" +#include #include using namespace Multiplayer::Animation; -static const float DEFAULT_RADIUS = NPC_ANIM_PROXIMITY; +static const float DEFAULT_RADIUS = 5.0f; // Location 0 is the camera origin, and the last location is overhead — skip both static const int FIRST_VALID_LOCATION = 1; static const int LAST_VALID_LOCATION = sizeOfArray(g_locations) - 2; -LocationProximity::LocationProximity() : m_nearestLocation(-1), m_nearestDistance(0.0f), m_radius(DEFAULT_RADIUS) +LocationProximity::LocationProximity() : 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); + std::vector prev = m_locations; + m_locations = ComputeAll(p_x, p_z, m_radius); + return m_locations != prev; +} - 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; +bool LocationProximity::IsAtLocation(int16_t p_location) const +{ + return std::find(m_locations.begin(), m_locations.end(), p_location) != m_locations.end(); } void LocationProximity::Reset() { - m_nearestLocation = -1; - m_nearestDistance = 0.0f; + m_locations.clear(); } -int16_t LocationProximity::ComputeNearest(float p_x, float p_z, float p_radius) +std::vector LocationProximity::ComputeAll(float p_x, float p_z, float p_radius) { - float bestDist = p_radius; - int16_t bestLocation = -1; + std::vector result; for (int i = FIRST_VALID_LOCATION; i <= LAST_VALID_LOCATION; i++) { float dx = p_x - g_locations[i].m_position[0]; float dz = p_z - g_locations[i].m_position[2]; float dist = std::sqrt(dx * dx + dz * dz); - if (dist < bestDist) { - bestDist = dist; - bestLocation = static_cast(i); + if (dist < p_radius) { + result.push_back(static_cast(i)); } } - return bestLocation; + // Sorted by index (iteration order is already ascending), which gives stable comparison + return result; } diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 34c5774b..ab411df2 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include using namespace Extensions; @@ -39,7 +41,7 @@ static constexpr float NPC_ANIM_NEARBY_RADIUS_SQ = (Animation::NPC_ANIM_PROXIMITY + 5.0f) * (Animation::NPC_ANIM_PROXIMITY + 5.0f); static const char* IDLE_ANIM_STATE_JSON = - "{\"location\":-1,\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}"; + "{\"locations\":[],\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}"; static void ExtractSlotPeerIds(const AnimUpdateMsg& p_msg, uint32_t p_out[8]) { @@ -137,11 +139,10 @@ MxResult NetworkManager::Tickle() if (userActor && userActor->GetROI()) { const float* pos = userActor->GetROI()->GetWorldPosition(); if (m_locationProximity.Update(pos[0], pos[2])) { - int16_t loc = m_locationProximity.GetNearestLocation(); m_animStateDirty = true; Animation::CoordinationState oldState = m_animCoordinator.GetState(); - m_animCoordinator.OnLocationChanged(loc, &m_animCatalog); + m_animCoordinator.OnLocationChanged(m_locationProximity.GetLocations(), &m_animCatalog); // Location change cleared interest — send cancel to host if (oldState != Animation::CoordinationState::e_idle && @@ -861,23 +862,29 @@ void NetworkManager::ProcessIncomingPackets() void NetworkManager::UpdateRemotePlayers(float p_deltaTime) { float radius = m_locationProximity.GetRadius(); - int16_t localLoc = m_locationProximity.GetNearestLocation(); + const auto& localLocs = m_locationProximity.GetLocations(); bool anyInIsle = false; for (auto& [peerId, player] : m_remotePlayers) { player->Tick(p_deltaTime); - // Derive nearest location from remote player's current position + // Derive locations from remote player's current position // Skip players not in the isle world — their position is stale if (player->IsSpawned() && player->GetROI() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) { anyInIsle = true; - int16_t oldLoc = player->GetNearestLocation(); + auto oldLocs = player->GetLocations(); const float* pos = player->GetROI()->GetWorldPosition(); - int16_t newLoc = Animation::LocationProximity::ComputeNearest(pos[0], pos[2], radius); - player->SetNearestLocation(newLoc); - if (oldLoc != newLoc && (oldLoc == localLoc || newLoc == localLoc)) { - m_animStateDirty = true; + auto newLocs = Animation::LocationProximity::ComputeAll(pos[0], pos[2], radius); + player->SetLocations(std::move(newLocs)); + if (oldLocs != player->GetLocations()) { + // Dirty if remote's locations changed and any overlap with local player's locations + for (int16_t loc : localLocs) { + if (player->IsAtLocation(loc) || std::find(oldLocs.begin(), oldLocs.end(), loc) != oldLocs.end()) { + m_animStateDirty = true; + break; + } + } } } } @@ -1058,8 +1065,12 @@ void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId) { auto it = m_remotePlayers.find(p_peerId); if (it != m_remotePlayers.end()) { - if (it->second->GetNearestLocation() == m_locationProximity.GetNearestLocation()) { - m_animStateDirty = true; + const auto& localLocs = m_locationProximity.GetLocations(); + for (int16_t loc : it->second->GetLocations()) { + if (std::find(localLocs.begin(), localLocs.end(), loc) != localLocs.end()) { + m_animStateDirty = true; + break; + } } if (it->second->GetROI()) { m_roiToPlayer.erase(it->second->GetROI()); @@ -1251,7 +1262,7 @@ void NetworkManager::TickHostSessions() if (entry && entry->location >= 0) { std::vector toRemove; for (const auto& slot : session->slots) { - if (slot.peerId != 0 && GetPeerLocation(slot.peerId) != entry->location) { + if (slot.peerId != 0 && !IsPeerAtLocation(slot.peerId, entry->location)) { toRemove.push_back(slot.peerId); } } @@ -1311,7 +1322,7 @@ void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, // For location-bound animations, player must be at that location const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); if (entry && entry->location >= 0) { - if (GetPeerLocation(p_peerId) != entry->location) { + if (!IsPeerAtLocation(p_peerId, entry->location)) { return; } } @@ -1704,16 +1715,16 @@ void NetworkManager::HandleAnimComplete(const AnimCompleteMsg& p_msg) m_callbacks->OnAnimationCompleted(json.c_str()); } -int16_t NetworkManager::GetPeerLocation(uint32_t p_peerId) const +bool NetworkManager::IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const { if (p_peerId == m_localPeerId) { - return m_locationProximity.GetNearestLocation(); + return m_locationProximity.IsAtLocation(p_location); } auto it = m_remotePlayers.find(p_peerId); if (it != m_remotePlayers.end()) { - return it->second->GetNearestLocation(); + return it->second->IsAtLocation(p_location); } - return -1; + return false; } bool NetworkManager::GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const @@ -1775,8 +1786,7 @@ bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex) if (slot.peerId == 0) { continue; } - int16_t loc = GetPeerLocation(slot.peerId); - if (loc >= 0 && loc != entry->location) { + if (!IsPeerAtLocation(slot.peerId, entry->location)) { return false; } } @@ -1978,7 +1988,7 @@ void NetworkManager::PushAnimationState() return; } - int16_t location = m_locationProximity.GetNearestLocation(); + const auto& locations = m_locationProximity.GetLocations(); uint8_t displayActorIndex = cam->GetDisplayActorIndex(); int8_t localCharIndex = Animation::Catalog::DisplayActorToCharacterIndex(displayActorIndex); @@ -1992,46 +2002,75 @@ void NetworkManager::PushAnimationState() const float* localPos = userActor->GetROI()->GetWorldPosition(); float localX = localPos[0], localZ = localPos[2]; - // Build two sets of character indices: - // - locationCharIndices: players at the same location (for cam anims) - // - proximityCharIndices: players within NPC_ANIM_PROXIMITY (for NPC anims) - std::vector locationCharIndices; + // Build proximity character indices (for NPC anims — position-based, not location-based) std::vector proximityCharIndices; - locationCharIndices.push_back(localCharIndex); proximityCharIndices.push_back(localCharIndex); for (const auto& [peerId, player] : m_remotePlayers) { if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { continue; } - int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); - if (player->GetNearestLocation() == location) { - locationCharIndices.push_back(charIdx); - } // Exact NPC_ANIM_PROXIMITY radius for triggering eligibility // (tighter than IsPeerNearby's NPC_ANIM_NEARBY_RADIUS_SQ used for session visibility) const float* rpos = player->GetROI()->GetWorldPosition(); float dx = rpos[0] - localX; float dz = rpos[2] - localZ; if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); proximityCharIndices.push_back(charIdx); } } - auto eligibility = m_animCoordinator.ComputeEligibility( - location, - locationCharIndices.data(), - static_cast(locationCharIndices.size()), - proximityCharIndices.data(), - static_cast(proximityCharIndices.size()) - ); + // Compute eligibility across all overlapping locations. + // Each call returns NPC anims + cam anims for that specific location. + // NPC anims are identical across calls (same proximityChars), so we deduplicate by animIndex. + std::vector eligibility; + std::set seenAnimIndices; + + // If at no location, still process once with -1 to get NPC anims + std::vector locationsToProcess = locations.empty() ? std::vector{int16_t(-1)} : locations; + + for (int16_t loc : locationsToProcess) { + // Build per-location character indices (for cam anims at this location) + std::vector locationCharIndices; + locationCharIndices.push_back(localCharIndex); + + for (const auto& [peerId, player] : m_remotePlayers) { + if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { + continue; + } + if (player->IsAtLocation(loc)) { + int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); + locationCharIndices.push_back(charIdx); + } + } + + auto locEligibility = m_animCoordinator.ComputeEligibility( + loc, + locationCharIndices.data(), + static_cast(locationCharIndices.size()), + proximityCharIndices.data(), + static_cast(proximityCharIndices.size()) + ); + + for (auto& info : locEligibility) { + if (seenAnimIndices.insert(info.animIndex).second) { + eligibility.push_back(std::move(info)); + } + } + } // Build JSON std::string json; json.reserve(2048); - json += "{\"location\":"; - json += std::to_string(location); - json += ",\"state\":"; + json += "{\"locations\":["; + for (size_t i = 0; i < locations.size(); i++) { + if (i > 0) { + json += ','; + } + json += std::to_string(locations[i]); + } + json += "],\"state\":"; json += std::to_string(static_cast(m_animCoordinator.GetState())); json += ",\"currentAnimIndex\":"; json += std::to_string(m_animCoordinator.GetCurrentAnimIndex()); diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index bba4c15e..c3684df1 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -15,6 +15,7 @@ #include #include +#include #include using namespace Extensions; @@ -29,7 +30,7 @@ using Common::WORLD_NOT_VISIBLE; RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex) : m_peerId(p_peerId), m_actorId(p_actorId), m_displayActorIndex(p_displayActorIndex), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f), m_targetVehicleType(VEHICLE_NONE), - m_targetWorldId(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), m_nearestLocation(-1), + m_targetWorldId(WORLD_NOT_VISIBLE), m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false), m_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}), m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true), m_animationLocked(false) { @@ -55,6 +56,11 @@ RemotePlayer::~RemotePlayer() Despawn(); } +bool RemotePlayer::IsAtLocation(int16_t p_location) const +{ + return std::find(m_locations.begin(), m_locations.end(), p_location) != m_locations.end(); +} + void RemotePlayer::Spawn(LegoWorld* p_isleWorld) { if (m_spawned) {