diff --git a/extensions/include/extensions/multiplayer/animation/catalog.h b/extensions/include/extensions/multiplayer/animation/catalog.h index 89dae742..bc1def50 100644 --- a/extensions/include/extensions/multiplayer/animation/catalog.h +++ b/extensions/include/extensions/multiplayer/animation/catalog.h @@ -36,6 +36,7 @@ struct CatalogEntry { 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 + uint8_t vehicleMask; // Bitmask of g_vehicles[] indices required (bit0=bikebd..bit6=board) }; class Catalog { @@ -56,11 +57,13 @@ class Catalog { static bool CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex); // Check if a set of character indices can collectively trigger this animation. + // p_onVehicle: parallel array indicating if each player is riding their vehicle (nullable). // p_filledPerformers: bitmask of which performer bits in performerMask are covered. // p_spectatorFilled: whether a valid spectator was found among unassigned players. bool CanTrigger( const CatalogEntry* p_entry, const int8_t* p_charIndices, + const uint8_t* p_onVehicle, uint8_t p_count, uint64_t* p_filledPerformers, bool* p_spectatorFilled @@ -70,6 +73,19 @@ class Catalog { // Does NOT check performer exclusion — caller must do that if needed. static bool CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex); + // Vehicle riding state for eligibility checks. + enum VehicleState : uint8_t { + e_onFoot = 0, // Not riding anything + e_onOwnVehicle = 1, // Riding character's own vehicle (e.g. Pepper on skateboard) + e_onOtherVehicle = 2 // Riding a vehicle that isn't the character's own + }; + + // Check if a player's vehicle state is compatible with the animation's vehicle requirements. + static bool CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState); + + // Determine the vehicle state for a character given their current ride vehicle ROI. + static VehicleState GetVehicleState(int8_t p_charIndex, class LegoROI* p_vehicleROI); + // 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); diff --git a/extensions/include/extensions/multiplayer/animation/coordinator.h b/extensions/include/extensions/multiplayer/animation/coordinator.h index 33f42bdd..b0c71094 100644 --- a/extensions/include/extensions/multiplayer/animation/coordinator.h +++ b/extensions/include/extensions/multiplayer/animation/coordinator.h @@ -57,11 +57,14 @@ class Coordinator { // Compute eligibility for animations at a location. // p_locationChars: local player + remote players at the same location (for cam anims). // p_proximityChars: local player + remote players within proximity (for NPC anims). + // p_locationVehicles/p_proximityVehicles: parallel bool arrays indicating vehicle riding state. std::vector ComputeEligibility( int16_t p_location, const int8_t* p_locationChars, + const uint8_t* p_locationVehicles, uint8_t p_locationCount, const int8_t* p_proximityChars, + const uint8_t* p_proximityVehicles, uint8_t p_proximityCount ) const; diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index b5cb3a91..7615ab87 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -145,6 +145,7 @@ class NetworkManager : public MxCore { bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const; bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; + uint8_t GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const; bool ValidateSessionLocations(uint16_t p_animIndex); void ResetAnimationState(); @@ -195,6 +196,8 @@ class NetworkManager : public MxCore { bool m_showNameBubbles; bool m_lastCameraEnabled; + uint8_t m_lastVehicleState; + bool m_vehicleFilterLogPending; // TODO(vehicle-filter): Remove after verification bool m_wasInRestrictedArea; // NPC animation playback diff --git a/extensions/src/multiplayer/animation/catalog.cpp b/extensions/src/multiplayer/animation/catalog.cpp index 9bd3c179..cb79fc56 100644 --- a/extensions/src/multiplayer/animation/catalog.cpp +++ b/extensions/src/multiplayer/animation/catalog.cpp @@ -5,11 +5,36 @@ #include "legoactors.h" #include "legoanimationmanager.h" #include "misc.h" +#include "roi/legoroi.h" #include using namespace Multiplayer::Animation; +// Static mapping of character index to g_vehicles[] index. +// Mirrors g_characters[].m_vehicleId for characters that own a vehicle. +static int8_t GetCharacterVehicleId(int8_t p_charIndex) +{ + switch (p_charIndex) { + case 0: + return 6; // pepper -> board (skateboard) + case 3: + return 4; // nick -> motoni (motorcycle) + case 4: + return 5; // laura -> motola (motorcycle) + case 36: + return 2; // rd -> bikerd + case 37: + return 1; // pg -> bikepg + case 38: + return 0; // bd -> bikebd + case 39: + return 3; // sy -> bikesy + default: + return -1; + } +} + // Exact-match a model name against g_actorInfoInit[].m_name. // The engine's LegoAnimationManager::GetCharacterIndex uses 2-char prefix matching, // which causes false positives (e.g. "ladder" matching "laura"). We need exact @@ -77,6 +102,16 @@ void Catalog::Refresh(LegoAnimationManager* p_am) } } + // Compute vehicleMask from the pre-populated vehicle list (m_unk0x2a). + // Each entry is a g_vehicles[] index set during LoadWorldInfo for models + // with m_unk0x2c=1 that match a known vehicle name. + entry.vehicleMask = 0; + for (int k = 0; k < 3; k++) { + if (m_animsBase[i].m_unk0x2a[k] >= 0 && m_animsBase[i].m_unk0x2a[k] < 8) { + entry.vehicleMask |= (1 << m_animsBase[i].m_unk0x2a[k]); + } + } + // Categorize based on whether the animation has named character performers. // g_actorInfoInit layout: // 0-47: named characters (pepper through jk) @@ -178,6 +213,65 @@ bool Catalog::CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex return p_entry->spectatorMask == ALL_CORE_ACTORS_MASK; } +bool Catalog::CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState) +{ + int8_t vehicleId = GetCharacterVehicleId(p_charIndex); + if (vehicleId < 0) { + return true; // Character has no vehicle — no constraint (Mama, Papa, NPCs) + } + + bool animUsesVehicle = (p_entry->vehicleMask >> vehicleId) & 1; + + switch (p_vehicleState) { + case e_onOwnVehicle: + return animUsesVehicle; // Only animations that use this character's vehicle + case e_onOtherVehicle: + return false; // On a foreign vehicle — no animations eligible + default: // e_onFoot + return !animUsesVehicle; // Only animations that don't use this character's vehicle + } +} + +// Vehicle category grouping (matches ScenePlayer::GetVehicleCategory) +static int8_t GetVehicleCategory(int8_t p_vehicleIdx) +{ + if (p_vehicleIdx >= 0 && p_vehicleIdx <= 3) { + return 0; // bike + } + if (p_vehicleIdx >= 4 && p_vehicleIdx <= 5) { + return 1; // motorcycle + } + if (p_vehicleIdx == 6) { + return 2; // skateboard + } + return -1; +} + +Catalog::VehicleState Catalog::GetVehicleState(int8_t p_charIndex, LegoROI* p_vehicleROI) +{ + if (!p_vehicleROI || !p_vehicleROI->GetName()) { + return e_onFoot; + } + + int8_t charVehicleId = GetCharacterVehicleId(p_charIndex); + if (charVehicleId < 0) { + return e_onFoot; // Character has no vehicle — treat any ride as irrelevant + } + + MxU32 rideVehicleIdx; + if (!AnimationManager()->FindVehicle(p_vehicleROI->GetName(), rideVehicleIdx)) { + return e_onOtherVehicle; // Unknown vehicle — treat as foreign + } + + // Compare by category — the ride system uses representative names (bikebd/motoni/board) + // that may differ from the character's specific vehicle index but share the same category. + if (GetVehicleCategory((int8_t) rideVehicleIdx) == GetVehicleCategory(charVehicleId)) { + return e_onOwnVehicle; + } + + return e_onOtherVehicle; +} + bool Catalog::CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex) { if (p_charIndex < 0) { @@ -201,6 +295,7 @@ bool Catalog::CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActor bool Catalog::CanTrigger( const CatalogEntry* p_entry, const int8_t* p_charIndices, + const uint8_t* p_onVehicle, uint8_t p_count, uint64_t* p_filledPerformers, bool* p_spectatorFilled @@ -220,6 +315,10 @@ bool Catalog::CanTrigger( uint64_t charBit = uint64_t(1) << charIndex; if ((p_entry->performerMask & charBit) && !(*p_filledPerformers & charBit)) { + if (p_onVehicle && !CheckVehicleEligibility(p_entry, charIndex, p_onVehicle[i])) { + continue; + } + *p_filledPerformers |= charBit; assignedAsPerformer[i] = true; } diff --git a/extensions/src/multiplayer/animation/coordinator.cpp b/extensions/src/multiplayer/animation/coordinator.cpp index d7faafd0..2208cd0d 100644 --- a/extensions/src/multiplayer/animation/coordinator.cpp +++ b/extensions/src/multiplayer/animation/coordinator.cpp @@ -82,8 +82,10 @@ static void BuildSlots( std::vector Coordinator::ComputeEligibility( int16_t p_location, const int8_t* p_locationChars, + const uint8_t* p_locationVehicles, uint8_t p_locationCount, const int8_t* p_proximityChars, + const uint8_t* p_proximityVehicles, uint8_t p_proximityCount ) const { @@ -101,9 +103,18 @@ std::vector Coordinator::ComputeEligibility( continue; } + // Vehicle eligibility: only filter if the local player would be a performer. + // Spectator-only roles remain visible so players on vehicles can still watch nearby scenes. + if ((entry->performerMask >> p_locationChars[0]) & 1) { + if (!Catalog::CheckVehicleEligibility(entry, p_locationChars[0], p_locationVehicles[0])) { + continue; + } + } + // NPC anims (location == -1): use proximity characters // Cam anims (location >= 0): use location characters const int8_t* chars = (entry->location == -1) ? p_proximityChars : p_locationChars; + const uint8_t* vehicles = (entry->location == -1) ? p_proximityVehicles : p_locationVehicles; uint8_t count = (entry->location == -1) ? p_proximityCount : p_locationCount; EligibilityInfo info; @@ -117,7 +128,7 @@ std::vector Coordinator::ComputeEligibility( bool spectatorFilled = false; if (atLoc) { - info.eligible = m_catalog->CanTrigger(entry, chars, count, &filledPerformers, &spectatorFilled); + info.eligible = m_catalog->CanTrigger(entry, chars, vehicles, count, &filledPerformers, &spectatorFilled); } else { info.eligible = false; diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 7d95f197..6fd2e1df 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -20,6 +20,7 @@ #include "mxticklemanager.h" #include "roi/legoroi.h" +#include #include #include #include @@ -67,9 +68,10 @@ NetworkManager::NetworkManager() m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), m_showNameBubbles(true), - m_lastCameraEnabled(false), m_wasInRestrictedArea(false), m_animStateDirty(false), m_animInterestDirty(false), - m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), - m_reconnectDelay(0), m_nextReconnectTime(0) + m_lastCameraEnabled(false), m_lastVehicleState(0), m_vehicleFilterLogPending(false), + m_wasInRestrictedArea(false), m_animStateDirty(false), + m_animInterestDirty(false), m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), + m_reconnectAttempt(0), m_reconnectDelay(0), m_nextReconnectTime(0) { } @@ -118,6 +120,31 @@ MxResult NetworkManager::Tickle() } } + // Detect vehicle state changes for animation eligibility refresh. + // Tracks three states: on foot, on own vehicle, on foreign vehicle. + int8_t localChar = Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()); + uint8_t vehicleState = Animation::Catalog::GetVehicleState(localChar, cam->GetRideVehicleROI()); + if (vehicleState != m_lastVehicleState) { + m_lastVehicleState = vehicleState; + m_animStateDirty = true; + m_vehicleFilterLogPending = true; + + // Cancel active session if the current animation is no longer eligible. + // Only cancel if the local player is a performer — spectators aren't vehicle-constrained. + if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) { + uint16_t currentAnim = m_animCoordinator.GetCurrentAnimIndex(); + if (currentAnim != Animation::ANIM_INDEX_NONE) { + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(currentAnim); + if (entry && (entry->performerMask >> localChar) & 1) { + if (!Animation::Catalog::CheckVehicleEligibility(entry, localChar, vehicleState)) { + CancelLocalAnimInterest(); + StopScenePlayback(currentAnim, false); + } + } + } + } + } + // Create local name bubble when display ROI becomes available if (m_showNameBubbles && !m_localNameBubble && cam->GetDisplayROI()) { char name[8]; @@ -1323,6 +1350,29 @@ void NetworkManager::TickHostSessions() } } + // Auto-remove participants whose vehicle state no longer matches + if (entry && entry->vehicleMask) { + std::vector toRemove; + for (const auto& slot : session->slots) { + if (slot.peerId != 0 && !slot.IsSpectator()) { + int8_t charIdx = slot.charIndex; + uint8_t onVehicle = GetPeerVehicleState(slot.peerId, charIdx); + if (!Animation::Catalog::CheckVehicleEligibility(entry, charIdx, onVehicle)) { + toRemove.push_back(slot.peerId); + } + } + } + for (uint32_t pid : toRemove) { + std::vector changed; + m_animSessionHost.HandleCancel(pid, changed); + BroadcastChangedSessions(changed); + } + session = m_animSessionHost.FindSession(animIndex); + if (!session) { + continue; + } + } + bool allFilled = m_animSessionHost.AreAllSlotsFilled(animIndex); bool coLocated = allFilled && ValidateSessionLocations(animIndex); @@ -1373,6 +1423,17 @@ void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, } } + // Validate vehicle eligibility if the joining player would be a performer + if (entry) { + int8_t charIndex = Animation::Catalog::DisplayActorToCharacterIndex(p_displayActorIndex); + if ((entry->performerMask >> charIndex) & 1) { + uint8_t onVehicle = GetPeerVehicleState(p_peerId, charIndex); + if (!Animation::Catalog::CheckVehicleEligibility(entry, charIndex, onVehicle)) { + return; + } + } + } + // For NPC anims: if all slots are full, remove far-away participants to make room // for the new nearby player. This only fires when slots are exhausted — if there's // an open slot, the new player just joins normally without disturbing anyone. @@ -1832,6 +1893,20 @@ bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ; } +uint8_t NetworkManager::GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const +{ + if (p_peerId == m_localPeerId) { + ThirdPersonCamera::Controller* cam = GetCamera(); + return cam ? Animation::Catalog::GetVehicleState(p_charIndex, cam->GetRideVehicleROI()) + : Animation::Catalog::e_onFoot; + } + auto it = m_remotePlayers.find(p_peerId); + if (it == m_remotePlayers.end() || !it->second->IsSpawned()) { + return Animation::Catalog::e_onFoot; + } + return Animation::Catalog::GetVehicleState(p_charIndex, it->second->GetRideVehicleROI()); +} + bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex) { const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); @@ -2066,9 +2141,11 @@ void NetworkManager::PushAnimationState() const float* localPos = userActor->GetROI()->GetWorldPosition(); float localX = localPos[0], localZ = localPos[2]; - // Build proximity character indices (for NPC anims — position-based, not location-based) + // Build proximity character indices and vehicle state (for NPC anims — position-based, not location-based) std::vector proximityCharIndices; + std::vector proximityVehicleState; proximityCharIndices.push_back(localCharIndex); + proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI())); for (const auto& [peerId, player] : m_remotePlayers) { if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { @@ -2082,6 +2159,7 @@ void NetworkManager::PushAnimationState() if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) { int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); proximityCharIndices.push_back(charIdx); + proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI())); } } @@ -2095,9 +2173,11 @@ void NetworkManager::PushAnimationState() 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) + // Build per-location character indices and vehicle state (for cam anims at this location) std::vector locationCharIndices; + std::vector locationVehicleState; locationCharIndices.push_back(localCharIndex); + locationVehicleState.push_back(Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI())); for (const auto& [peerId, player] : m_remotePlayers) { if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) { @@ -2106,14 +2186,17 @@ void NetworkManager::PushAnimationState() if (player->IsAtLocation(loc)) { int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex()); locationCharIndices.push_back(charIdx); + locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI())); } } auto locEligibility = m_animCoordinator.ComputeEligibility( loc, locationCharIndices.data(), + locationVehicleState.data(), static_cast(locationCharIndices.size()), proximityCharIndices.data(), + proximityVehicleState.data(), static_cast(proximityCharIndices.size()) ); @@ -2124,6 +2207,37 @@ void NetworkManager::PushAnimationState() } } + // TODO(vehicle-filter): Remove this logging block after verification + if (m_vehicleFilterLogPending) { + m_vehicleFilterLogPending = false; + uint8_t vehState = Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI()); + const char* stateNames[] = {"onFoot", "onOwnVehicle", "onOtherVehicle"}; + SDL_Log( + "[VehicleFilter] Vehicle state changed: char=%d state=%s — %zu eligible animations", + localCharIndex, + stateNames[vehState < 3 ? vehState : 0], + eligibility.size() + ); + uint32_t vehicleAnimCount = 0; + for (const auto& info : eligibility) { + const AnimInfo* ai = m_animCatalog.GetAnimInfo(info.animIndex); + if (ai && info.entry && info.entry->vehicleMask) { + vehicleAnimCount++; + SDL_Log( + " [%u] %s (objId=%u loc=%d vmask=0x%02x)", + info.animIndex, + ai->m_name, + ai->m_objectId, + ai->m_location, + info.entry->vehicleMask + ); + } + } + if (vehicleAnimCount == 0) { + SDL_Log(" (no vehicle animations in eligible set)"); + } + } + // Build JSON std::string json; json.reserve(2048);