Support concurrent animation playback by independent player groups

Replace the single ScenePlayer/m_playingAnimIndex with a map of
ScenePlayers keyed by animation index, allowing non-overlapping groups
of players to play different animations simultaneously. Each player can
still only participate in one animation at a time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-27 15:57:12 -07:00
parent 343610ece5
commit f92967735f
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
10 changed files with 176 additions and 79 deletions

View File

@ -69,6 +69,8 @@ class Coordinator {
void OnLocationChanged(const std::vector<int16_t>& p_locations, const Catalog* p_catalog);
void Reset();
void ResetLocalState();
void RemoveSession(uint16_t p_animIndex);
// Apply authoritative session state from host
void ApplySessionUpdate(

View File

@ -45,8 +45,9 @@ class ScenePlayer {
void Tick();
void Stop();
bool IsPlaying() const { return m_playing; }
bool IsObserverMode() const { return m_observerMode; }
void PreloadAsync(uint32_t p_objectId) { m_loader.PreloadAsync(p_objectId); }
void SetLoader(Loader* p_loader) { m_loader = p_loader; }
private:
void ComputeRebaseMatrix();
@ -56,7 +57,7 @@ class ScenePlayer {
void CleanupProps();
// Sub-components
Loader m_loader;
Loader* m_loader;
AudioPlayer m_audioPlayer;
PhonemePlayer m_phonemePlayer;

View File

@ -33,12 +33,13 @@ class SessionHost {
uint32_t p_peerId,
uint16_t p_animIndex,
uint8_t p_displayActorIndex,
std::vector<uint16_t>& p_changedAnims);
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);
// Returns animIndices of all sessions ready to play
std::vector<uint16_t> Tick(uint32_t p_now);
void StartCountdown(uint16_t p_animIndex);
void RevertCountdown(uint16_t p_animIndex);
@ -66,7 +67,8 @@ class SessionHost {
void RemovePlayerFromSessions(
uint32_t p_peerId,
bool p_includePlayingSessions,
std::vector<uint16_t>& p_changedAnims);
std::vector<uint16_t>& p_changedAnims
);
const Catalog* m_catalog = nullptr;
std::map<uint16_t, AnimSession> m_sessions;

View File

@ -199,15 +199,19 @@ class NetworkManager : public MxCore {
// NPC animation playback
Multiplayer::Animation::Catalog m_animCatalog;
Multiplayer::Animation::ScenePlayer m_scenePlayer;
Multiplayer::Animation::Loader m_animLoader;
Multiplayer::Animation::LocationProximity m_locationProximity;
Multiplayer::Animation::Coordinator m_animCoordinator;
Multiplayer::Animation::SessionHost m_animSessionHost;
int32_t m_localPendingAnimInterest;
uint16_t m_playingAnimIndex;
// Concurrent animation playback: one ScenePlayer per playing animation
std::map<uint16_t, std::unique_ptr<Multiplayer::Animation::ScenePlayer>> m_playingAnims;
void TickAnimation();
void StopScenePlayback(bool p_unlockRemotes);
void StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes);
void StopAllPlayback();
void UnlockRemotesForAnim(uint16_t p_animIndex);
// Animation state push
bool m_animStateDirty;

View File

@ -2,6 +2,7 @@
#include "extensions/common/characteranimator.h"
#include "extensions/common/customizestate.h"
#include "extensions/multiplayer/animation/catalog.h"
#include "extensions/multiplayer/protocol.h"
#include "mxtypes.h"
@ -58,8 +59,15 @@ class RemotePlayer {
const char* GetDisplayName() const { return m_displayName; }
void SetAnimationLocked(bool p_locked) { m_animationLocked = p_locked; }
bool IsAnimationLocked() const { return m_animationLocked; }
void LockForAnimation(uint16_t p_animIndex) { m_lockedForAnimIndex = p_animIndex; }
void UnlockFromAnimation(uint16_t p_animIndex)
{
if (m_lockedForAnimIndex == p_animIndex) {
m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE;
}
}
void ForceUnlockAnimation() { m_lockedForAnimIndex = Animation::ANIM_INDEX_NONE; }
bool IsAnimationLocked() const { return m_lockedForAnimIndex != Animation::ANIM_INDEX_NONE; }
private:
const char* GetDisplayActorName() const;
@ -100,7 +108,7 @@ class RemotePlayer {
Extensions::Common::CustomizeState m_customizeState;
bool m_allowRemoteCustomize;
bool m_animationLocked;
uint16_t m_lockedForAnimIndex;
};
} // namespace Multiplayer

View File

@ -185,6 +185,18 @@ void Coordinator::Reset()
m_cancelPending = false;
}
void Coordinator::ResetLocalState()
{
m_state = CoordinationState::e_idle;
m_currentAnimIndex = ANIM_INDEX_NONE;
m_cancelPending = false;
}
void Coordinator::RemoveSession(uint16_t p_animIndex)
{
m_sessions.erase(p_animIndex);
}
void Coordinator::ApplySessionUpdate(
uint16_t p_animIndex,
uint8_t p_state,

View File

@ -61,9 +61,9 @@ static bool MatchesCharacter(const std::string& p_actorName, int8_t p_charIndex)
}
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)
: m_loader(nullptr), 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)
{
}
@ -264,7 +264,7 @@ void ScenePlayer::Play(
return;
}
SceneAnimData* data = m_loader.EnsureCached(p_animInfo->m_objectId);
SceneAnimData* data = m_loader->EnsureCached(p_animInfo->m_objectId);
if (!data || !data->anim) {
return;
}

View File

@ -221,16 +221,16 @@ void SessionHost::RevertCountdown(uint16_t p_animIndex)
}
}
uint16_t SessionHost::Tick(uint32_t p_now)
std::vector<uint16_t> SessionHost::Tick(uint32_t p_now)
{
std::vector<uint16_t> ready;
for (auto& [animIndex, session] : m_sessions) {
if (session.state == CoordinationState::e_countdown && p_now >= session.countdownEndTime) {
session.state = CoordinationState::e_playing;
return animIndex;
ready.push_back(animIndex);
}
}
return ANIM_INDEX_NONE;
return ready;
}
void SessionHost::Reset()

View File

@ -69,11 +69,10 @@ NetworkManager::NetworkManager()
m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true),
m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false),
m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false),
m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1),
m_playingAnimIndex(Animation::ANIM_INDEX_NONE), 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_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)
{
}
@ -105,8 +104,11 @@ MxResult NetworkManager::Tickle()
// Cancel animation when camera is disabled (vehicle entry, restricted area, etc.)
if (!cameraEnabled && m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex();
CancelLocalAnimInterest();
StopScenePlayback(false);
if (localAnim != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnim, false);
}
}
if (m_localNameBubble) {
@ -314,14 +316,7 @@ void NetworkManager::CancelLocalAnimInterest()
void NetworkManager::StopAnimation()
{
ResetAnimationState();
if (m_scenePlayer.IsPlaying()) {
m_scenePlayer.Stop();
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
}
StopAllPlayback();
}
void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
@ -562,8 +557,11 @@ void NetworkManager::ProcessPendingRequests()
if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) {
if (cam->IsEnabled()) {
if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex();
CancelLocalAnimInterest();
StopScenePlayback(false);
if (localAnim != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnim, false);
}
}
cam->Disable();
NotifyThirdPersonChanged(false);
@ -1189,19 +1187,46 @@ void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType
SendMessage(msg);
}
void NetworkManager::StopScenePlayback(bool p_unlockRemotes)
void NetworkManager::StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes)
{
if (!m_scenePlayer.IsPlaying()) {
auto it = m_playingAnims.find(p_animIndex);
if (it == m_playingAnims.end()) {
return;
}
m_scenePlayer.Stop();
m_playingAnimIndex = Animation::ANIM_INDEX_NONE;
// Save before Stop() which resets the flag
bool wasObserver = it->second->IsObserverMode();
if (it->second->IsPlaying()) {
it->second->Stop();
}
if (p_unlockRemotes) {
for (auto& [peerId, player] : m_remotePlayers) {
player->SetAnimationLocked(false);
UnlockRemotesForAnim(p_animIndex);
}
// Release camera if local player was a participant (not observer) in this animation
if (!wasObserver) {
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
}
m_playingAnims.erase(it);
}
void NetworkManager::StopAllPlayback()
{
for (auto& [animIndex, scenePlayer] : m_playingAnims) {
if (scenePlayer->IsPlaying()) {
scenePlayer->Stop();
}
}
m_playingAnims.clear();
for (auto& [peerId, player] : m_remotePlayers) {
player->ForceUnlockAnimation();
}
ThirdPersonCamera::Controller* cam = GetCamera();
@ -1210,32 +1235,56 @@ void NetworkManager::StopScenePlayback(bool p_unlockRemotes)
}
}
void NetworkManager::UnlockRemotesForAnim(uint16_t p_animIndex)
{
for (auto& [peerId, player] : m_remotePlayers) {
player->UnlockFromAnimation(p_animIndex);
}
}
void NetworkManager::TickAnimation()
{
if (!m_scenePlayer.IsPlaying()) {
if (m_playingAnims.empty()) {
return;
}
m_scenePlayer.Tick();
// Collect completed animations with their observer mode (Tick/Stop resets the flag)
std::vector<std::pair<uint16_t, bool>> completed;
if (!m_scenePlayer.IsPlaying()) {
for (auto& [peerId, player] : m_remotePlayers) {
player->SetAnimationLocked(false);
for (auto& [animIndex, scenePlayer] : m_playingAnims) {
if (!scenePlayer->IsPlaying()) {
completed.push_back({animIndex, scenePlayer->IsObserverMode()});
continue;
}
bool wasObserver = scenePlayer->IsObserverMode();
scenePlayer->Tick();
if (!scenePlayer->IsPlaying()) {
completed.push_back({animIndex, wasObserver});
}
}
for (auto& [animIndex, wasObserver] : completed) {
UnlockRemotesForAnim(animIndex);
// Release camera if local player was a participant (not observer)
if (!wasObserver) {
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
if (IsHost() && m_playingAnimIndex != Animation::ANIM_INDEX_NONE) {
BroadcastAnimComplete(m_playingAnimIndex); // Must fire before EraseSession destroys participant data
m_animSessionHost.EraseSession(m_playingAnimIndex);
BroadcastAnimUpdate(m_playingAnimIndex); // Broadcast cleared state
m_animCoordinator.ResetLocalState();
m_animCoordinator.RemoveSession(animIndex);
}
m_playingAnimIndex = Animation::ANIM_INDEX_NONE;
m_animCoordinator.Reset();
if (IsHost()) {
BroadcastAnimComplete(animIndex); // Must fire before EraseSession destroys participant data
m_animSessionHost.EraseSession(animIndex);
BroadcastAnimUpdate(animIndex); // Broadcast cleared state
}
m_playingAnims.erase(animIndex);
m_animStateDirty = true;
m_animInterestDirty = true;
}
@ -1286,7 +1335,7 @@ void NetworkManager::TickHostSessions()
if (m_animCoordinator.IsLocalPlayerInSession(animIndex)) {
const AnimInfo* ai = m_animCatalog.GetAnimInfo(animIndex);
if (ai) {
m_scenePlayer.PreloadAsync(ai->m_objectId);
m_animLoader.PreloadAsync(ai->m_objectId);
}
}
@ -1300,9 +1349,9 @@ void NetworkManager::TickHostSessions()
}
}
// Check countdown expiry
uint16_t readyAnim = m_animSessionHost.Tick(SDL_GetTicks());
if (readyAnim != Animation::ANIM_INDEX_NONE) {
// Check countdown expiry — multiple animations may be ready simultaneously
std::vector<uint16_t> readyAnims = m_animSessionHost.Tick(SDL_GetTicks());
for (uint16_t readyAnim : readyAnims) {
BroadcastAnimStart(readyAnim);
HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim));
}
@ -1363,6 +1412,7 @@ void NetworkManager::HandleAnimCancel(uint32_t p_peerId)
return;
}
uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex();
Animation::CoordinationState oldState = m_animCoordinator.GetState();
std::vector<uint16_t> changedAnims;
@ -1371,9 +1421,18 @@ void NetworkManager::HandleAnimCancel(uint32_t p_peerId)
m_animInterestDirty = true;
}
// Stop local player's animation if their session was erased
if (oldState == Animation::CoordinationState::e_playing &&
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) {
StopScenePlayback(true);
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle &&
localAnimBefore != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnimBefore, true);
}
// Stop observer-mode playback for any erased playing sessions
for (uint16_t animIndex : changedAnims) {
if (animIndex != localAnimBefore && m_playingAnims.count(animIndex)) {
StopScenePlayback(animIndex, true);
}
}
}
@ -1383,6 +1442,7 @@ void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg)
return; // Host already updated its own state
}
uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex();
Animation::CoordinationState oldState = m_animCoordinator.GetState();
uint32_t slots[8];
@ -1393,7 +1453,7 @@ void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg)
if (p_msg.state == static_cast<uint8_t>(Animation::CoordinationState::e_countdown)) {
const AnimInfo* ai = m_animCatalog.GetAnimInfo(p_msg.animIndex);
if (ai) {
m_scenePlayer.PreloadAsync(ai->m_objectId);
m_animLoader.PreloadAsync(ai->m_objectId);
}
}
@ -1402,14 +1462,16 @@ void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg)
m_localPendingAnimInterest = -1;
}
// Stop local player's animation if their session was cleared
if (oldState == Animation::CoordinationState::e_playing &&
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) {
StopScenePlayback(true);
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle &&
localAnimBefore != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnimBefore, true);
}
// Stop observer playback when the observed session is cleared
if (m_scenePlayer.IsPlaying() && m_playingAnimIndex == p_msg.animIndex && p_msg.state == 0) {
StopScenePlayback(true);
if (m_playingAnims.count(p_msg.animIndex) && p_msg.state == 0) {
StopScenePlayback(p_msg.animIndex, true);
}
m_animStateDirty = true;
@ -1438,7 +1500,7 @@ void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localIn
m_animSessionHost.EraseSession(p_animIndex);
BroadcastAnimUpdate(p_animIndex);
}
m_animCoordinator.Reset();
m_animCoordinator.ResetLocalState();
}
m_animStateDirty = true;
};
@ -1496,7 +1558,7 @@ void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localIn
// Lock performers to prevent network updates from fighting animation
if (!rp.IsSpectator()) {
it->second->SetAnimationLocked(true);
it->second->LockForAnimation(p_animIndex);
}
}
}
@ -1515,26 +1577,31 @@ void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localIn
return;
}
auto scenePlayer = std::make_unique<Animation::ScenePlayer>();
scenePlayer->SetLoader(&m_animLoader);
if (!observerMode) {
bool localIsPerformer = (localCharIndex >= 0);
cam->SetAnimPlaying(true, localIsPerformer, [this]() { m_scenePlayer.Stop(); });
cam->SetAnimPlaying(true, localIsPerformer, [this, p_animIndex]() {
auto it = m_playingAnims.find(p_animIndex);
if (it != m_playingAnims.end()) {
it->second->Stop();
}
});
}
m_scenePlayer.Play(animInfo, entry->category, participants.data(), (uint8_t) participants.size(), observerMode);
scenePlayer->Play(animInfo, entry->category, participants.data(), (uint8_t) participants.size(), observerMode);
if (!m_scenePlayer.IsPlaying()) {
if (!scenePlayer->IsPlaying()) {
if (!observerMode) {
cam->SetAnimPlaying(false);
}
// Unlock remote players on failure
for (auto& [peerId, player] : m_remotePlayers) {
player->SetAnimationLocked(false);
}
UnlockRemotesForAnim(p_animIndex);
abortSession();
return;
}
m_playingAnimIndex = p_animIndex;
m_playingAnims[p_animIndex] = std::move(scenePlayer);
m_localPendingAnimInterest = -1;
m_animStateDirty = true;
}
@ -1842,7 +1909,7 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg)
it->second->GetCustomizeState(),
p_msg.changeType == CHANGE_MOOD
);
if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote() && !m_scenePlayer.IsPlaying()) {
if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote() && !it->second->IsAnimationLocked()) {
it->second->StopClickAnimation();
MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation(
it->second->GetROI(),

View File

@ -32,7 +32,8 @@ RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displ
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_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}),
m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true), m_animationLocked(false)
m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true),
m_lockedForAnimIndex(Animation::ANIM_INDEX_NONE)
{
m_displayName[0] = '\0';
const char* displayName = GetDisplayActorName();
@ -205,7 +206,7 @@ void RemotePlayer::Tick(float p_deltaTime)
// 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 (IsAnimationLocked()) {
if (m_nameBubble) {
m_nameBubble->Update(m_roi);
}