Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public struct CharacterEmoteComponent
public bool StopEmote;
public AvatarEmoteMask Mask;

public float PlayingTime;

private int currentAnimationTag;

public float PlayingEmoteDuration => CurrentEmoteReference?.avatarClip
Expand All @@ -35,6 +37,7 @@ public void Reset()
CurrentEmoteReference = null;
StopEmote = false;
Mask = AvatarEmoteMask.AemFullBody;
PlayingTime = 0f;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ namespace DCL.AvatarRendering.Emotes.Play
[UpdateBefore(typeof(ChangeCharacterPositionGroup))]
public partial class CharacterEmoteSystem : BaseUnityLoopSystem
{
private const float STUCK_EMOTE_STOP_RETRY_PERIOD = 1f;
private const float EMOTE_OVERRUN_GRACE = 1f;

private static readonly string SCENE_EMOTE_PREFIX_WITH_COLON = GetSceneEmoteFromRealmIntention.SCENE_EMOTE_PREFIX + ":";

// todo: use this to add nice Debug UI to trigger any emote?
Expand Down Expand Up @@ -90,6 +93,7 @@ protected override void Update(float t)
CancelMaskedEmotesByDeletionQuery(World);
UpdateEmoteTagsQuery(World);
UpdateRemoteMaskedEmoteTagsQuery(World);
ForceStopStuckEmotesQuery(World, t);
DisableCharacterControllerQuery(World);
CleanUpQuery(World);
}
Expand Down Expand Up @@ -233,10 +237,7 @@ private void StopEmote(Entity entity, ref CharacterEmoteComponent emoteComponent
// Legacy emotes keep the Mecanim animator disabled while playing
avatarView.StopLegacyAnimation();

// Create a clean slate for the animator before setting the stop trigger
avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE);
avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE_RESET);
avatarView.SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
ResetAnimatorToStopEmote(avatarView);

// See https://github.com/decentraland/unity-explorer/issues/4198
// Some emotes changes the armature rotation, we need to restore it
Expand All @@ -249,6 +250,14 @@ private void StopEmote(Entity entity, ref CharacterEmoteComponent emoteComponent
emoteComponent.Reset();
}

private static void ResetAnimatorToStopEmote(IAvatarView avatarView)
{
avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE);
avatarView.ResetAnimatorTrigger(AnimationHashes.EMOTE_RESET);
avatarView.SetAnimatorBool(AnimationHashes.EMOTE_LOOP, false);
avatarView.SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
}

/// <summary>
/// Handles cancellation of masked emotes on remote player entities in the global world.
/// Local player masked emotes are handled by SceneMaskedEmoteSystem in each scene world.
Expand All @@ -263,6 +272,45 @@ private void CancelRemoteMaskedEmotes(ref CharacterMaskedEmoteComponent masked,
private void UpdateRemoteMaskedEmoteTags(ref CharacterMaskedEmoteComponent masked, in IAvatarView avatarView) =>
EmotePlayer.UpdateMaskedEmoteTag(ref masked, avatarView);

// This query recovers avatars whose animator remains in an emote-tagged state after the emote should have ended
[Query]
[None(typeof(CharacterEmoteIntent), typeof(DeleteEntityIntention))]
private void ForceStopStuckEmotes([Data] float dt, Entity entity, ref CharacterEmoteComponent emoteComponent, in IAvatarView avatarView)
{
if (!emoteComponent.IsPlayingEmote)
{
emoteComponent.PlayingTime = 0f;
return;
}

EmoteReferences? emoteReference = emoteComponent.CurrentEmoteReference;

if (emoteReference == null)
{
emoteComponent.PlayingTime += dt;

if (emoteComponent.PlayingTime < STUCK_EMOTE_STOP_RETRY_PERIOD) return;

ReportHub.LogWarning(GetReportData(), "Animator stuck in an emote state without an emote reference, re-issuing the stop trigger");
ResetAnimatorToStopEmote(avatarView);
emoteComponent.PlayingTime = 0f;
return;
}

if (emoteComponent.EmoteLoop || emoteReference.legacy)
{
emoteComponent.PlayingTime = 0f;
return;
}

emoteComponent.PlayingTime += dt;

if (emoteComponent.PlayingTime < emoteComponent.PlayingEmoteDuration + EMOTE_OVERRUN_GRACE) return;

ReportHub.LogWarning(GetReportData(), $"Emote {emoteComponent.EmoteUrn} exceeded its expected duration, force-stopping");
StopEmote(entity, ref emoteComponent, avatarView);
}

// This query takes care of consuming the CharacterEmoteIntent to trigger an emote
[Query]
[None(typeof(DeleteEntityIntention), typeof(PlayerTeleportIntent.JustTeleported))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ public bool Play(GameObject mainAsset, AudioClip? audioAsset, bool isLooping, bo

emotesInUse.Add(emoteReferences, pools[mainAsset]);
emoteComponent.CurrentEmoteReference = emoteReferences;

emoteComponent.PlayingTime = 0f;
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,15 @@ public void IsPlayingEmote_ReturnsTrue_WhenAnimatorInEmoteLoopTag()

Assert.IsTrue(emoteComponent.IsPlayingEmote);
}

[Test]
public void Reset_ZeroesPlayingTime()
{
var emoteComponent = new CharacterEmoteComponent { PlayingTime = 5f };

emoteComponent.Reset();

Assert.AreEqual(0f, emoteComponent.PlayingTime);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using Arch.Core;
using DCL.AvatarRendering.AvatarShape.UnityInterface;
using DCL.AvatarRendering.Emotes.Play;
using DCL.DebugUtilities;
using DCL.Multiplayer.Emotes;
using ECS.SceneLifeCycle;
using ECS.TestSuite;
using NSubstitute;
using NUnit.Framework;
using UnityEngine;
using Utility.Animations;
using Object = UnityEngine.Object;

namespace DCL.AvatarRendering.Emotes.Tests
{
/// <summary>
/// Covers the stuck-emote watchdog (https://github.com/decentraland/unity-explorer/issues/9115):
/// the avatar must always recover when the animator never leaves an emote-tagged state.
/// </summary>
public class CharacterEmoteSystemShould : UnitySystemTestBase<CharacterEmoteSystem>
{
private IAvatarView avatarView = null!;
private IEmotesMessageBus messageBus = null!;
private GameObject poolRoot = null!;
private GameObject audioSourcePrefab = null!;
private GameObject emoteReferencesGameObject = null!;
private EmoteMaskCatalog emoteMaskCatalog = null!;

[SetUp]
public void SetUp()
{
poolRoot = new GameObject("ROOT_POOL_CONTAINER");
audioSourcePrefab = new GameObject("AudioSourcePrefab");
emoteReferencesGameObject = new GameObject(nameof(CharacterEmoteSystemShould));
emoteMaskCatalog = ScriptableObject.CreateInstance<EmoteMaskCatalog>();

var emotePlayer = new EmotePlayer(audioSourcePrefab.AddComponent<AudioSource>(), emoteMaskCatalog);

messageBus = Substitute.For<IEmotesMessageBus>();

system = new CharacterEmoteSystem(
world,
Substitute.For<IEmoteStorage>(),
messageBus,
emotePlayer,
Substitute.For<IDebugContainerBuilder>(),
false,
Substitute.For<IScenesCache>());

avatarView = Substitute.For<IAvatarView>();
}

protected override void OnTearDown()
{
if (poolRoot != null) Object.DestroyImmediate(poolRoot);
if (audioSourcePrefab != null) Object.DestroyImmediate(audioSourcePrefab);
if (emoteReferencesGameObject != null) Object.DestroyImmediate(emoteReferencesGameObject);
if (emoteMaskCatalog != null) Object.DestroyImmediate(emoteMaskCatalog);
}

[Test]
public void ReissueStopTrigger_WhenAnimatorStuckInEmoteTagWithoutReference()
{
// Arrange: the animator reports an emote tag but the emote was already torn down (no reference)
avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE);
world.Create(new CharacterEmoteComponent(), avatarView);

// Act: exceed the retry period
system!.Update(0.5f);
system.Update(0.5f);

// Assert
avatarView.Received(1).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
avatarView.Received(1).SetAnimatorBool(AnimationHashes.EMOTE_LOOP, false);

// Act: a further period elapses while still stuck
system.Update(0.5f);
system.Update(0.5f);

// Assert: the stop trigger is re-issued periodically, not every frame
avatarView.Received(2).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
}

[Test]
public void NotReissueStopTrigger_WithinTransitionGraceWindow()
{
// Arrange: same dead-end state, but less time than the retry period elapses
avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE);
world.Create(new CharacterEmoteComponent(), avatarView);

// Act
system!.Update(0.9f);

// Assert: the normal post-stop transition window is left alone
avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
}

[Test]
public void ForceStopEmote_WhenNonLoopingEmoteExceedsDuration()
{
// Arrange: a non-looping emote whose animator never leaves the emote tag
avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE);
EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent<EmoteReferences>();
emoteReferences.Initialize(null, null, null, null, 0, legacy: false);
Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }, avatarView);

// Act: exceed PlayingEmoteDuration (0 for a null clip) + grace
system!.Update(0.6f);
system.Update(0.6f);

// Assert: full teardown ran, without broadcasting a stop for a non-player entity
Assert.IsNull(world.Get<CharacterEmoteComponent>(entity).CurrentEmoteReference);
avatarView.Received(1).SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
messageBus.DidNotReceive().SendStop();
}

[Test]
public void NotStop_LoopingEmote()
{
// Arrange: an intentionally looping emote
avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER).Returns(AnimationHashes.EMOTE_LOOP);
EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent<EmoteReferences>();
emoteReferences.Initialize(null, null, null, null, 0, legacy: false);
Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences, EmoteLoop = true }, avatarView);

// Act: simulate a long time playing
for (var i = 0; i < 30; i++)
system!.Update(1f);

// Assert
Assert.IsNotNull(world.Get<CharacterEmoteComponent>(entity).CurrentEmoteReference);
avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
}

[Test]
public void NotStop_LegacyEmoteViaWatchdog()
{
// Arrange: legacy emotes are torn down by the IsLegacyAnimationPlaying poll, not by the watchdog
avatarView.IsLegacyAnimationPlaying.Returns(true);
EmoteReferences emoteReferences = emoteReferencesGameObject.AddComponent<EmoteReferences>();
emoteReferences.Initialize(null, null, null, null, 0, legacy: true);
Entity entity = world.Create(new CharacterEmoteComponent { CurrentEmoteReference = emoteReferences }, avatarView);

// Act
for (var i = 0; i < 30; i++)
system!.Update(1f);

// Assert
Assert.IsNotNull(world.Get<CharacterEmoteComponent>(entity).CurrentEmoteReference);
avatarView.DidNotReceive().SetAnimatorTrigger(AnimationHashes.EMOTE_STOP);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading