From 11bf29039658afb7c7afa779385307933b8c5c1f Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Fri, 3 Apr 2026 20:21:02 -0700 Subject: [PATCH] Fix animation system to work when host is outside ISLE world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move TickHostSessions outside m_inIsleWorld gate so the host can coordinate animations from any world - Load animation catalog early in HandleCreate so the host can coordinate before entering the ISLE world - Use network-reported positions for remote player location detection instead of requiring spawned ROIs - Always erase sessions at launch — the host's job ends when the animation starts; clients play and complete independently - Replace BroadcastAnimComplete with locally-driven completion callbacks: host generates eventId at launch, clients cache completion JSON at start time, fire it when ScenePlayer finishes - Make StopAnimation only do local cleanup (stop playback, cancel own interest, reset coordinator) without destroying the session host, so other players' sessions survive world transitions - Broadcast state=0 in ResetAnimationState for full teardown paths (shutdown, reconnect, host migration) so clients aren't left with stale session state --- .../multiplayer/animation/sessionhost.h | 3 +- .../extensions/multiplayer/networkmanager.h | 11 +- .../include/extensions/multiplayer/protocol.h | 18 +- .../extensions/multiplayer/remoteplayer.h | 6 + extensions/src/multiplayer/networkmanager.cpp | 349 +++++++++--------- 5 files changed, 192 insertions(+), 195 deletions(-) diff --git a/extensions/include/extensions/multiplayer/animation/sessionhost.h b/extensions/include/extensions/multiplayer/animation/sessionhost.h index 139acece..ae43080b 100644 --- a/extensions/include/extensions/multiplayer/animation/sessionhost.h +++ b/extensions/include/extensions/multiplayer/animation/sessionhost.h @@ -15,7 +15,8 @@ struct SessionSlot { uint32_t peerId; // 0 = unfilled int8_t charIndex; // g_actorInfoInit index, or -1 for spectator - bool IsSpectator() const { return charIndex < 0; } + bool IsSpectator() const { return IsSpectatorCharIndex(charIndex); } + static bool IsSpectatorCharIndex(int8_t p_charIndex) { return p_charIndex < 0; } }; struct AnimSession { diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index b5696b9f..391ffb81 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -137,13 +137,11 @@ class NetworkManager : public MxCore { 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); + void HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession, uint64_t p_eventId); AnimUpdateMsg BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target); void BroadcastAnimUpdate(uint16_t p_animIndex); void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId); - void BroadcastAnimStart(uint16_t p_animIndex); - void BroadcastAnimComplete(uint16_t p_animIndex); - void HandleAnimComplete(const AnimCompleteMsg& p_msg); + void BroadcastAnimStart(uint16_t p_animIndex, uint64_t p_eventId); bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const; bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; @@ -216,6 +214,11 @@ class NetworkManager : public MxCore { // Concurrent animation playback: one ScenePlayer per playing animation std::map> m_playingAnims; + // Pre-built completion JSON per playing animation (non-observer participants only). + // Cached at animation start so it survives host migration/dropout. + std::map m_pendingCompletionJson; + std::string BuildCompletionJson(uint16_t p_animIndex, uint64_t p_eventId); + void TickAnimation(); void StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes); void StopAllPlayback(); diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index b64897d7..1acf4c83 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -32,7 +32,6 @@ enum MessageType : uint8_t { MSG_ANIM_CANCEL = 12, MSG_ANIM_UPDATE = 13, MSG_ANIM_START = 14, - MSG_ANIM_COMPLETE = 15, MSG_HORN = 16, MSG_ASSIGN_ID = 0xFF }; @@ -197,22 +196,7 @@ struct AnimUpdateMsg { struct AnimStartMsg { MessageHeader header; uint16_t animIndex; -}; - -// Per-participant data in AnimCompleteMsg -struct AnimCompletionParticipant { - uint32_t peerId; - int8_t charIndex; // Participant's character (g_actorInfoInit index) - char displayName[USERNAME_BUFFER_SIZE]; // 7 chars + null -}; - -// Host -> All: animation completed successfully (natural completion only, not cancellation) -struct AnimCompleteMsg { - MessageHeader header; - uint64_t eventId; // Random 64-bit ID unique to this completion event - uint16_t animIndex; // World-encoded animation index (globally unique key) - uint8_t participantCount; - AnimCompletionParticipant participants[8]; + uint64_t eventId; // Random 64-bit ID for the completion event (host-generated, same for all clients) }; #pragma pack(pop) diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 56e5960d..2ce036d5 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -43,6 +43,12 @@ class RemotePlayer { const std::vector& GetLocations() const { return m_locations; } void SetLocations(std::vector p_locations) { m_locations = std::move(p_locations); } bool IsAtLocation(int16_t p_location) const; + bool HasReceivedUpdate() const { return m_hasReceivedUpdate; } + void GetTargetPosition(float& p_x, float& p_z) const + { + p_x = m_targetPosition[0]; + p_z = m_targetPosition[2]; + } uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } void SetVisible(bool p_visible); void TriggerExtraAnim(uint8_t p_emoteId); diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index e0ceef77..c47f63f3 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -193,14 +193,15 @@ MxResult NetworkManager::Tickle() } } - if (IsHost()) { - TickHostSessions(); - } - else if (m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) { + if (!IsHost() && m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) { m_animStateDirty = true; } } + if (IsHost()) { + TickHostSessions(); + } + if (!m_transport) { return SUCCESS; } @@ -259,6 +260,12 @@ void NetworkManager::HandleCreate() TickleManager()->RegisterClient(this, 10); m_registered = true; } + + // Load animation catalog early so the host can coordinate animations + // even before entering the Isle world (e.g. while in infocenter). + m_animCatalog.Refresh(); + m_animCoordinator.SetCatalog(&m_animCatalog); + m_animSessionHost.SetCatalog(&m_animCatalog); } void NetworkManager::Shutdown() @@ -312,8 +319,22 @@ bool NetworkManager::WasRejected() const void NetworkManager::ResetAnimationState() { + // Notify clients that all active sessions are cancelled so they don't get stuck + // waiting for a countdown/start that will never come. + if (IsHost() && IsConnected()) { + std::vector activeAnims; + for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) { + activeAnims.push_back(animIndex); + } + m_animSessionHost.Reset(); + for (uint16_t animIndex : activeAnims) { + SendMessage(BuildAnimUpdateMsg(animIndex, TARGET_BROADCAST)); // Session gone → state=0 + } + } + else { + m_animSessionHost.Reset(); + } m_animCoordinator.Reset(); - m_animSessionHost.Reset(); m_localPendingAnimInterest = -1; m_pendingAnimInterest.store(-1, std::memory_order_relaxed); m_pendingAnimCancel.store(false, std::memory_order_relaxed); @@ -348,8 +369,12 @@ void NetworkManager::CancelLocalAnimInterest() void NetworkManager::StopAnimation() { - ResetAnimationState(); StopAllPlayback(); + CancelLocalAnimInterest(); + m_animCoordinator.Reset(); + m_pendingAnimInterest.store(-1, std::memory_order_relaxed); + m_pendingAnimCancel.store(false, std::memory_order_relaxed); + m_animStateDirty = true; } void NetworkManager::OnWorldEnabled(LegoWorld* p_world) @@ -909,13 +934,6 @@ void NetworkManager::ProcessIncomingPackets() } break; } - case MSG_ANIM_COMPLETE: { - AnimCompleteMsg msg; - if (DeserializeMsg(data, length, msg)) { - HandleAnimComplete(msg); - } - break; - } default: break; } @@ -931,14 +949,16 @@ void NetworkManager::UpdateRemotePlayers(float p_deltaTime) for (auto& [peerId, player] : m_remotePlayers) { player->Tick(p_deltaTime); - // 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) { + // Derive locations from remote player's network-reported position. + // Skip players not in the isle world — their position is stale. + if (player->GetWorldId() == (int8_t) LegoOmni::e_act1 && player->HasReceivedUpdate()) { anyInIsle = true; + float px, pz; + player->GetTargetPosition(px, pz); + auto oldLocs = player->GetLocations(); - const float* pos = player->GetROI()->GetWorldPosition(); - auto newLocs = Animation::LocationProximity::ComputeAll(pos[0], pos[2], radius); + auto newLocs = Animation::LocationProximity::ComputeAll(px, pz, radius); player->SetLocations(std::move(newLocs)); if (oldLocs != player->GetLocations()) { // Dirty if remote's locations changed and any overlap with local player's locations @@ -1384,6 +1404,7 @@ void NetworkManager::StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemote } } + m_pendingCompletionJson.erase(p_animIndex); m_playingAnims.erase(it); } @@ -1395,6 +1416,7 @@ void NetworkManager::StopAllPlayback() } } m_playingAnims.clear(); + m_pendingCompletionJson.clear(); for (auto& [peerId, player] : m_remotePlayers) { player->ForceUnlockAnimation(); @@ -1439,20 +1461,21 @@ void NetworkManager::TickAnimation() for (auto& [animIndex, wasObserver] : completed) { UnlockRemotesForAnim(animIndex); - // Release camera if local player was a participant (not observer) if (!wasObserver) { + // Fire cached completion callback before cleanup destroys state + auto compIt = m_pendingCompletionJson.find(animIndex); + if (compIt != m_pendingCompletionJson.end()) { + if (m_callbacks && !compIt->second.empty()) { + m_callbacks->OnAnimationCompleted(compIt->second.c_str()); + } + m_pendingCompletionJson.erase(compIt); + } + ThirdPersonCamera::Controller* cam = GetCamera(); if (cam) { cam->SetAnimPlaying(false); } m_animCoordinator.ResetLocalState(); - m_animCoordinator.RemoveSession(animIndex); - } - - if (IsHost()) { - BroadcastAnimComplete(animIndex); // Must fire before EraseSession destroys participant data - m_animSessionHost.EraseSession(animIndex); - BroadcastAnimUpdate(animIndex); // Broadcast cleared state } m_playingAnims.erase(animIndex); @@ -1547,8 +1570,16 @@ void NetworkManager::TickHostSessions() // Check countdown expiry — multiple animations may be ready simultaneously std::vector readyAnims = m_animSessionHost.Tick(SDL_GetTicks()); for (uint16_t readyAnim : readyAnims) { - BroadcastAnimStart(readyAnim); - HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim)); + uint64_t eventId = (static_cast(SDL_rand_bits()) << 32) | static_cast(SDL_rand_bits()); + BroadcastAnimStart(readyAnim, eventId); + + // Erase session immediately — the host's job ends at launch. + // Clients play independently and fire completion callbacks locally. + m_animSessionHost.EraseSession(readyAnim); + + if (m_inIsleWorld) { + HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim), eventId); + } } // During countdown, push state every tick so countdownMs reaches the frontend @@ -1692,13 +1723,13 @@ void NetworkManager::HandleAnimStart(const AnimStartMsg& p_msg) } m_animCoordinator.ApplyAnimStart(p_msg.animIndex); - HandleAnimStartLocally(p_msg.animIndex, m_animCoordinator.IsLocalPlayerInSession(p_msg.animIndex)); + HandleAnimStartLocally(p_msg.animIndex, m_animCoordinator.IsLocalPlayerInSession(p_msg.animIndex), p_msg.eventId); m_animStateDirty = true; m_animInterestDirty = true; } -void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession) +void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession, uint64_t p_eventId) { auto abortSession = [&]() { // Observers must not abort the authoritative session — only participants may do that @@ -1816,10 +1847,112 @@ void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localIn } m_playingAnims[p_animIndex] = std::move(scenePlayer); + + // Cache completion JSON for local firing when ScenePlayer finishes. + // Must happen before RemoveSession which destroys the session view. + if (!observerMode && m_callbacks) { + m_pendingCompletionJson[p_animIndex] = BuildCompletionJson(p_animIndex, p_eventId); + } + + // Session data has been captured — free the animation for reuse so all clients + // (not just the host) show it as available immediately after launch. + m_animCoordinator.RemoveSession(p_animIndex); + m_localPendingAnimInterest = -1; m_animStateDirty = true; } +std::string NetworkManager::BuildCompletionJson(uint16_t p_animIndex, uint64_t p_eventId) +{ + const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex); + const Animation::SessionView* view = m_animCoordinator.GetSessionView(p_animIndex); + if (!entry || !view) { + return {}; + } + + std::vector slotChars = Animation::SessionHost::ComputeSlotCharIndices(entry); + uint8_t count = view->slotCount < static_cast(slotChars.size()) + ? view->slotCount + : static_cast(slotChars.size()); + + char eventIdHex[17]; + SDL_snprintf( + eventIdHex, + sizeof(eventIdHex), + "%08x%08x", + static_cast(p_eventId >> 32), + static_cast(p_eventId & 0xFFFFFFFF) + ); + + std::string json = "{\"eventId\":\""; + json += eventIdHex; + json += "\",\"animIndex\":"; + json += std::to_string(p_animIndex); + json += ",\"participants\":["; + + // Emit local player first so frontend can rely on participants[0] being self + bool first = true; + auto appendParticipant = [&](uint32_t peerId, uint8_t slotIndex) { + int8_t charIndex; + char displayName[USERNAME_BUFFER_SIZE] = {}; + + if (Animation::SessionSlot::IsSpectatorCharIndex(slotChars[slotIndex])) { + // Resolve spectator's actual character from their display actor + if (peerId == m_localPeerId) { + ThirdPersonCamera::Controller* cam = GetCamera(); + charIndex = cam ? Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()) : -1; + } + else { + auto it = m_remotePlayers.find(peerId); + charIndex = it != m_remotePlayers.end() + ? Animation::Catalog::DisplayActorToCharacterIndex(it->second->GetDisplayActorIndex()) + : -1; + } + } + else { + charIndex = slotChars[slotIndex]; + } + + if (peerId == m_localPeerId) { + EncodeUsername(displayName); + } + else { + auto it = m_remotePlayers.find(peerId); + if (it != m_remotePlayers.end()) { + SDL_strlcpy(displayName, it->second->GetDisplayName(), sizeof(displayName)); + } + } + + if (!first) { + json += ','; + } + first = false; + json += "{\"charIndex\":"; + json += std::to_string(static_cast(charIndex)); + json += ",\"displayName\":\""; + json += displayName; + json += "\"}"; + }; + + // Local player first + for (uint8_t i = 0; i < count; i++) { + if (view->peerSlots[i] == m_localPeerId) { + appendParticipant(m_localPeerId, i); + break; + } + } + // Then remote players + for (uint8_t i = 0; i < count; i++) { + uint32_t peerId = view->peerSlots[i]; + if (peerId != 0 && peerId != m_localPeerId) { + appendParticipant(peerId, i); + } + } + + json += "]}"; + return json; +} + AnimUpdateMsg NetworkManager::BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target) { AnimUpdateMsg msg{}; @@ -1855,147 +1988,18 @@ void NetworkManager::SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_tar SendMessage(BuildAnimUpdateMsg(p_animIndex, p_targetPeerId)); } -void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex) +void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex, uint64_t p_eventId) { AnimStartMsg msg{}; msg.header = MakeHeader(MSG_ANIM_START, TARGET_BROADCAST); msg.animIndex = p_animIndex; + msg.eventId = p_eventId; SendMessage(msg); // Also update local coordinator m_animCoordinator.ApplyAnimStart(p_animIndex); } -void NetworkManager::BroadcastAnimComplete(uint16_t p_animIndex) -{ - const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); - if (!session) { - return; - } - - const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex); - if (!animInfo) { - return; - } - - AnimCompleteMsg msg{}; - msg.header = MakeHeader(MSG_ANIM_COMPLETE, TARGET_BROADCAST); - msg.eventId = (static_cast(SDL_rand_bits()) << 32) | static_cast(SDL_rand_bits()); - msg.animIndex = p_animIndex; - msg.participantCount = 0; - - char localName[8]; - EncodeUsername(localName); - - for (const auto& slot : session->slots) { - if (slot.peerId == 0 || msg.participantCount >= 8) { - continue; - } - - AnimCompletionParticipant& p = msg.participants[msg.participantCount]; - p.peerId = slot.peerId; - - if (slot.IsSpectator()) { - // Resolve spectator's actual character from their display actor - if (slot.peerId == m_localPeerId) { - ThirdPersonCamera::Controller* cam = GetCamera(); - p.charIndex = cam ? Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()) : -1; - } - else { - auto it = m_remotePlayers.find(slot.peerId); - p.charIndex = it != m_remotePlayers.end() - ? Animation::Catalog::DisplayActorToCharacterIndex(it->second->GetDisplayActorIndex()) - : -1; - } - } - else { - p.charIndex = slot.charIndex; - } - - if (slot.peerId == m_localPeerId) { - SDL_memcpy(p.displayName, localName, sizeof(p.displayName)); - } - else { - auto it = m_remotePlayers.find(slot.peerId); - if (it != m_remotePlayers.end()) { - SDL_memcpy(p.displayName, it->second->GetDisplayName(), sizeof(p.displayName)); - } - else { - p.displayName[0] = '\0'; - } - } - - msg.participantCount++; - } - - SendMessage(msg); - - // Also handle locally on the host (message sent to TARGET_BROADCAST excludes sender) - HandleAnimComplete(msg); -} - -void NetworkManager::HandleAnimComplete(const AnimCompleteMsg& p_msg) -{ - // Only fire callback for actual participants, not observers - int localIdx = -1; - for (uint8_t i = 0; i < p_msg.participantCount; i++) { - if (p_msg.participants[i].peerId == m_localPeerId) { - localIdx = i; - break; - } - } - - if (localIdx < 0 || !m_callbacks) { - return; - } - - // Build JSON for frontend - char eventIdHex[17]; - SDL_snprintf( - eventIdHex, - sizeof(eventIdHex), - "%08x%08x", - static_cast(p_msg.eventId >> 32), - static_cast(p_msg.eventId & 0xFFFFFFFF) - ); - - std::string json = "{\"eventId\":\""; - json += eventIdHex; - json += "\",\"animIndex\":"; - json += std::to_string(p_msg.animIndex); - json += ",\"participants\":["; - - // Emit local player first so frontend can rely on participants[0] being self - bool first = true; - auto appendParticipant = [&](uint8_t i) { - if (!first) { - json += ','; - } - first = false; - const AnimCompletionParticipant& p = p_msg.participants[i]; - // Ensure null-termination safety for displayName - char name[USERNAME_BUFFER_SIZE]; - SDL_memcpy(name, p.displayName, sizeof(name)); - name[USERNAME_BUFFER_SIZE - 1] = '\0'; - json += "{\"charIndex\":"; - json += std::to_string(static_cast(p.charIndex)); - json += ",\"displayName\":\""; - json += name; - json += "\"}"; - }; - - appendParticipant(static_cast(localIdx)); - for (uint8_t i = 0; i < p_msg.participantCount; i++) { - if (i != static_cast(localIdx)) { - appendParticipant(i); - } - } - - json += "]}"; - - m_callbacks->OnAnimationCompleted(json.c_str()); -} - bool NetworkManager::IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const { if (p_peerId == m_localPeerId) { @@ -2021,13 +2025,11 @@ bool NetworkManager::GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) return false; } auto it = m_remotePlayers.find(p_peerId); - if (it != m_remotePlayers.end() && it->second->IsSpawned() && it->second->GetROI()) { - const float* pos = it->second->GetROI()->GetWorldPosition(); - p_x = pos[0]; - p_z = pos[2]; - return true; + if (it == m_remotePlayers.end() || !it->second->HasReceivedUpdate()) { + return false; } - return false; + it->second->GetTargetPosition(p_x, p_z); + return true; } bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const @@ -2039,13 +2041,14 @@ bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) return true; } auto it = m_remotePlayers.find(p_peerId); - if (it == m_remotePlayers.end() || !it->second->IsSpawned() || !it->second->GetROI() || - it->second->GetWorldId() != (int8_t) LegoOmni::e_act1) { + if (it == m_remotePlayers.end() || it->second->GetWorldId() != (int8_t) LegoOmni::e_act1 || + !it->second->HasReceivedUpdate()) { return false; } - const float* pos = it->second->GetROI()->GetWorldPosition(); - float dx = pos[0] - p_refX; - float dz = pos[2] - p_refZ; + float px, pz; + it->second->GetTargetPosition(px, pz); + float dx = px - p_refX; + float dz = pz - p_refZ; return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ; } @@ -2057,7 +2060,7 @@ uint8_t NetworkManager::GetPeerVehicleState(uint32_t p_peerId, int8_t p_charInde : Animation::Catalog::e_onFoot; } auto it = m_remotePlayers.find(p_peerId); - if (it == m_remotePlayers.end() || !it->second->IsSpawned()) { + if (it == m_remotePlayers.end()) { return Animation::Catalog::e_onFoot; } return Animation::Catalog::GetVehicleState(p_charIndex, it->second->GetRideVehicleROI());