Skip to content

Commit 677f189

Browse files
Merge branch 'dev' into bugsweep/toggle-hints-tooltip-position
2 parents 97f438b + df35da1 commit 677f189

81 files changed

Lines changed: 88119 additions & 142392 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Explorer/Assets/DCL/AvatarRendering/Emotes/Components/CharacterMaskedEmoteComponent.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ public struct CharacterMaskedEmoteComponent
1515

1616
private int currentAnimationTag;
1717

18+
public float PlayingEmoteDuration => CurrentEmoteReference?.avatarClip
19+
? CurrentEmoteReference.avatarClip.length * (CurrentEmoteReference.animatorComp != null ? CurrentEmoteReference.animatorComp.speed : 1f)
20+
: 0f;
21+
1822
public readonly int CurrentAnimationTag => currentAnimationTag;
1923

2024
public readonly bool IsPlaying

Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/CharacterEmoteSystem.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ private void ConsumeEmoteIntent([Data] float dt, Entity entity,
305305
}
306306

307307
// emote failed to load? remove intent
308-
if (emote.DTO.assetBundleManifestVersion is { assetBundleManifestRequestFailed: true } and { IsLSDAsset: false })
308+
if (emote.DTO is { assetBundleManifestVersion: { assetBundleManifestRequestFailed: true, IsLSDAsset: false } })
309309
{
310310
ReportHub.LogError(GetReportData(), $"Cant play emote {emoteId} since it failed loading the manifest");
311311
World.Remove<CharacterEmoteIntent>(entity);
@@ -324,12 +324,13 @@ private void ConsumeEmoteIntent([Data] float dt, Entity entity,
324324
}
325325

326326
BodyShape bodyShape = avatarShapeComponent.BodyShape;
327+
StreamableLoadingResult<AttachmentRegularAsset>? assetResult = emote.AssetResults[bodyShape];
327328

328329
// Loading not complete
329-
if (emote.AssetResults[bodyShape] == null)
330+
if (assetResult == null)
330331
return;
331332

332-
StreamableLoadingResult<AttachmentRegularAsset> streamableAssetValue = emote.AssetResults[bodyShape].Value;
333+
StreamableLoadingResult<AttachmentRegularAsset> streamableAssetValue = assetResult.Value;
333334
GameObject? mainAsset;
334335

335336
if (streamableAssetValue is { Succeeded: false } || (mainAsset = streamableAssetValue.Asset?.MainAsset) == null)
@@ -391,7 +392,8 @@ private void ConsumeEmoteIntent([Data] float dt, Entity entity,
391392
ReportHub.LogError(ReportCategory.EMOTE, $"Emote name:{emoteId} cant be played.");
392393
else
393394
{
394-
uint durationMs = !isLooping ? (uint)(emoteComponent.PlayingEmoteDuration * 1000) : 0;
395+
// The duration comes from the masked emote's own clip; a looping emote has no end to report.
396+
uint durationMs = !isLooping ? (uint)(masked.PlayingEmoteDuration * 1000) : 0;
395397
World.Add(entity, new EmotePendingToBroadcast { EmoteId = emoteId, DurationMs = durationMs, Mask = mask});
396398
}
397399
}

Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Play/SceneMaskedEmoteSystem.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ private void CancelMaskedEmotes(ref CharacterMaskedEmoteComponent masked)
8989

9090
AvatarBase view = mainPlayerAvatarBaseProxy.Object!;
9191

92+
// Both branches below tear down an emote that stopped by itself. A non-looping clip ran to its
93+
// end and has nothing left to resume, so its urn is cleared too; a looping one lost its animator
94+
// state to something else and keeps the urn to be resumed.
95+
9296
// Legacy-blender path: tear down once the blender signals natural completion.
9397
if (view.HasMaskedLegacyEmoteFinished)
9498
{
95-
TryStopMaskedEmote(ref masked);
99+
TryStopMaskedEmote(ref masked, permanent: !masked.EmoteLoop);
96100
return;
97101
}
98102

@@ -105,7 +109,7 @@ private void CancelMaskedEmotes(ref CharacterMaskedEmoteComponent masked)
105109
bool isOnAnotherTag = currentTag != AnimationHashes.MASKED_EMOTE && currentTag != AnimationHashes.MASKED_EMOTE_LOOP;
106110

107111
if (isOnAnotherTag)
108-
TryStopMaskedEmote(ref masked);
112+
TryStopMaskedEmote(ref masked, permanent: !masked.EmoteLoop);
109113
}
110114

111115
[Query]
@@ -180,7 +184,8 @@ private void ConsumeMaskedEmoteIntent([Data] float dt, Entity entity,
180184
ReportHub.LogError(ReportCategory.EMOTE, $"Emote name:{emoteId} cant be played.");
181185
else
182186
{
183-
uint durationMs = !isLooping ? (uint)(emoteComponent.PlayingEmoteDuration * 1000) : 0;
187+
// The duration comes from the masked emote's own clip; a looping emote has no end to report.
188+
uint durationMs = !isLooping ? (uint)(masked.PlayingEmoteDuration * 1000) : 0;
184189
globalWorld.Add(globalPlayerEntity, new EmotePendingToBroadcast { EmoteId = emoteId, DurationMs = durationMs, Mask = emoteIntent.Mask });
185190
}
186191

@@ -203,16 +208,25 @@ private void UpdateMaskedEmoteVisibility(ref CharacterMaskedEmoteComponent maske
203208
bool shouldPlay = isInScene && !fullBodyIsPlaying && !isGliding;
204209

205210
if (shouldPlay && masked.CurrentEmoteReference == null)
206-
ReplayMaskedEmote(ref masked, ec);
211+
ReplayMaskedEmote(ref masked);
207212
else if (!shouldPlay && masked.CurrentEmoteReference != null)
208213
TryStopMaskedEmote(ref masked);
209214
}
210215

211-
private void ReplayMaskedEmote(ref CharacterMaskedEmoteComponent masked, CharacterEmoteComponent emoteComponent)
216+
private void ReplayMaskedEmote(ref CharacterMaskedEmoteComponent masked)
212217
{
213218
if (!emoteStorage.TryGetElement(masked.EmoteUrn.Shorten(), out IEmote emote)) return;
214219
if (emote.IsLoading) return;
215220

221+
// Replaying resumes a looping emote that was suspended. A non-looping emote has already
222+
// delivered its single playback, so drop the urn instead of restarting it on every frame
223+
// that the play conditions hold.
224+
if (!emote.IsLooping())
225+
{
226+
masked.Reset();
227+
return;
228+
}
229+
216230
if (!globalWorld.TryGet(globalPlayerEntity, out AvatarShapeComponent avatarShape)) return;
217231

218232
BodyShape bodyShape = avatarShape.BodyShape;
@@ -231,17 +245,15 @@ private void ReplayMaskedEmote(ref CharacterMaskedEmoteComponent masked, Charact
231245

232246
IAvatarView avatarBase = mainPlayerAvatarBaseProxy.Object!;
233247

234-
bool isLooping = emote.IsLooping();
235-
236-
if (!emotePlayer.PlayMasked(mainAsset, audioClip, isLooping, true, in avatarBase, ref masked))
248+
if (!emotePlayer.PlayMasked(mainAsset, audioClip, isLooping: true, isSpatial: true, in avatarBase, ref masked))
237249
return;
238250

239251
// Reset stored tag so CancelMaskedEmotes doesn't fire on the next frame
240252
// before UpdateMaskedEmoteTags has a chance to set the real animator state.
241253
masked.SetAnimationTag(0);
242254

243-
uint durationMs = !isLooping ? (uint)(emoteComponent.PlayingEmoteDuration * 1000) : 0;
244-
globalWorld.Add(globalPlayerEntity, new EmotePendingToBroadcast { EmoteId = masked.EmoteUrn, DurationMs = durationMs, Mask = masked.Mask });
255+
// Only looping emotes are replayed, and a looping emote has no end to report.
256+
globalWorld.Add(globalPlayerEntity, new EmotePendingToBroadcast { EmoteId = masked.EmoteUrn, Mask = masked.Mask });
245257
}
246258

247259
/// <summary>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using DCL.ECSComponents;
2+
using NUnit.Framework;
3+
using UnityEngine;
4+
using Utility.Animations;
5+
6+
namespace DCL.AvatarRendering.Emotes.Tests
7+
{
8+
public class CharacterMaskedEmoteComponentShould
9+
{
10+
private const float CLIP_LENGTH = 2f;
11+
12+
private GameObject gameObject = null!;
13+
private EmoteReferences emoteReferences = null!;
14+
private AnimationClip clip = null!;
15+
16+
[SetUp]
17+
public void SetUp()
18+
{
19+
gameObject = new GameObject(nameof(CharacterMaskedEmoteComponentShould));
20+
emoteReferences = gameObject.AddComponent<EmoteReferences>();
21+
22+
clip = new AnimationClip { name = "TestClip" };
23+
clip.SetCurve(string.Empty, typeof(Transform), "localPosition.x", AnimationCurve.Linear(0, 0, CLIP_LENGTH, 1));
24+
}
25+
26+
[TearDown]
27+
public void TearDown()
28+
{
29+
if (gameObject != null) Object.DestroyImmediate(gameObject);
30+
if (clip != null) Object.DestroyImmediate(clip);
31+
}
32+
33+
[Test]
34+
public void PlayingEmoteDuration_ReturnsZero_WhenNoReferenceIsSet()
35+
{
36+
var masked = new CharacterMaskedEmoteComponent();
37+
38+
Assert.AreEqual(0f, masked.PlayingEmoteDuration, 0.001f);
39+
}
40+
41+
[Test]
42+
public void PlayingEmoteDuration_ReturnsZero_WhenReferenceHasNoAvatarClip()
43+
{
44+
emoteReferences.Initialize(null, null, null, null, 0, legacy: false);
45+
46+
var masked = new CharacterMaskedEmoteComponent { CurrentEmoteReference = emoteReferences };
47+
48+
Assert.AreEqual(0f, masked.PlayingEmoteDuration, 0.001f);
49+
}
50+
51+
[Test]
52+
public void PlayingEmoteDuration_ReturnsAvatarClipLength()
53+
{
54+
emoteReferences.Initialize(clip, null, null, null, 0, legacy: false);
55+
56+
var masked = new CharacterMaskedEmoteComponent { CurrentEmoteReference = emoteReferences };
57+
58+
Assert.AreEqual(CLIP_LENGTH, masked.PlayingEmoteDuration, 0.001f,
59+
"The broadcast duration of a masked emote comes from the masked clip itself, not from the full body emote.");
60+
}
61+
62+
[Test]
63+
public void PlayingEmoteDuration_ScalesWithAnimatorSpeed()
64+
{
65+
Animator animator = gameObject.AddComponent<Animator>();
66+
animator.speed = 2f;
67+
emoteReferences.Initialize(clip, null, animator, null, 0, legacy: false);
68+
69+
var masked = new CharacterMaskedEmoteComponent { CurrentEmoteReference = emoteReferences };
70+
71+
Assert.AreEqual(CLIP_LENGTH * 2f, masked.PlayingEmoteDuration, 0.001f);
72+
}
73+
74+
[Test]
75+
public void IsPlaying_ReturnsFalse_WhenNoReferenceIsSet()
76+
{
77+
var masked = new CharacterMaskedEmoteComponent();
78+
masked.SetAnimationTag(AnimationHashes.MASKED_EMOTE);
79+
80+
Assert.IsFalse(masked.IsPlaying);
81+
}
82+
83+
[Test]
84+
public void IsPlaying_ReturnsTrue_WhenAnimatorInMaskedEmoteLoopTag()
85+
{
86+
emoteReferences.Initialize(clip, null, null, null, 0, legacy: false);
87+
88+
var masked = new CharacterMaskedEmoteComponent { CurrentEmoteReference = emoteReferences };
89+
masked.SetAnimationTag(AnimationHashes.MASKED_EMOTE_LOOP);
90+
91+
Assert.IsTrue(masked.IsPlaying);
92+
}
93+
94+
[Test]
95+
public void Reset_ClearsTheEmoteUrn()
96+
{
97+
var masked = new CharacterMaskedEmoteComponent
98+
{
99+
EmoteUrn = "urn:decentraland:off-chain:scene-emote:test-scene-hash-false",
100+
EmoteLoop = true,
101+
CurrentEmoteReference = emoteReferences,
102+
Mask = AvatarEmoteMask.AemUpperBody,
103+
};
104+
105+
masked.Reset();
106+
107+
Assert.IsTrue(masked.EmoteUrn.IsNullOrEmpty(),
108+
"Clearing the urn is what stops a finished emote from being replayed.");
109+
Assert.IsNull(masked.CurrentEmoteReference);
110+
Assert.IsFalse(masked.EmoteLoop);
111+
}
112+
}
113+
}

Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/CharacterMaskedEmoteComponentShould.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using Arch.Core;
2+
using CommunicationData.URLHelpers;
3+
using DCL.AvatarRendering.AvatarShape.Components;
4+
using DCL.AvatarRendering.AvatarShape.UnityInterface;
5+
using DCL.AvatarRendering.Emotes.Play;
6+
using DCL.AvatarRendering.Loading.Assets;
7+
using DCL.AvatarRendering.Loading.Components;
8+
using DCL.ECSComponents;
9+
using DCL.Multiplayer.Emotes;
10+
using DCL.Utilities;
11+
using ECS.StreamableLoading.Common.Components;
12+
using ECS.TestSuite;
13+
using NSubstitute;
14+
using NUnit.Framework;
15+
using SceneRunner.Scene;
16+
using UnityEngine;
17+
using Object = UnityEngine.Object;
18+
19+
namespace DCL.AvatarRendering.Emotes.Tests
20+
{
21+
public class SceneMaskedEmoteSystemShould : UnitySystemTestBase<SceneMaskedEmoteSystem>
22+
{
23+
private const string EMOTE_URN_FORMAT = "urn:decentraland:off-chain:scene-emote:test-scene-bafkreiemotehash-{0}";
24+
25+
private World globalWorld = null!;
26+
private Entity globalPlayerEntity;
27+
private IEmoteStorage emoteStorage = null!;
28+
private EmoteMaskCatalog emoteMaskCatalog = null!;
29+
private GameObject poolRoot = null!;
30+
private GameObject audioSourcePrefab = null!;
31+
private GameObject avatarBaseGameObject = null!;
32+
33+
[SetUp]
34+
public void SetUp()
35+
{
36+
// EmotePlayer resolves its pool parent with GameObject.Find("ROOT_POOL_CONTAINER") and
37+
// throws without it, so the object has to exist before the constructor runs.
38+
poolRoot = new GameObject("ROOT_POOL_CONTAINER");
39+
40+
audioSourcePrefab = new GameObject("EmoteAudioSource");
41+
AudioSource audioSource = audioSourcePrefab.AddComponent<AudioSource>();
42+
emoteMaskCatalog = ScriptableObject.CreateInstance<EmoteMaskCatalog>();
43+
var emotePlayer = new EmotePlayer(audioSource, emoteMaskCatalog, legacyAnimationsEnabled: true);
44+
45+
globalWorld = World.Create();
46+
globalPlayerEntity = globalWorld.Create(new AvatarShapeComponent { BodyShape = BodyShape.MALE });
47+
48+
avatarBaseGameObject = new GameObject(nameof(AvatarBase));
49+
var avatarBaseProxy = new ObjectProxy<AvatarBase>();
50+
avatarBaseProxy.SetObject(avatarBaseGameObject.AddComponent<AvatarBase>());
51+
52+
emoteStorage = Substitute.For<IEmoteStorage>();
53+
54+
// The player stands in the scene that triggered the emote, so the play conditions are met.
55+
ISceneStateProvider sceneStateProvider = Substitute.For<ISceneStateProvider>();
56+
sceneStateProvider.IsCurrent.Returns(true);
57+
58+
system = new SceneMaskedEmoteSystem(world, globalWorld, globalPlayerEntity, avatarBaseProxy,
59+
emotePlayer, emoteStorage, Substitute.For<IEmotesMessageBus>(), sceneStateProvider);
60+
}
61+
62+
protected override void OnTearDown()
63+
{
64+
globalWorld.Dispose();
65+
Object.DestroyImmediate(emoteMaskCatalog);
66+
Object.DestroyImmediate(avatarBaseGameObject);
67+
Object.DestroyImmediate(audioSourcePrefab);
68+
Object.DestroyImmediate(poolRoot);
69+
}
70+
71+
[Test]
72+
public void DiscardNonLoopingEmoteThatAlreadyPlayed()
73+
{
74+
Entity entity = CreateSuspendedMaskedEmote(loop: false);
75+
76+
system!.Update(0);
77+
78+
CharacterMaskedEmoteComponent masked = world.Get<CharacterMaskedEmoteComponent>(entity);
79+
80+
Assert.IsTrue(masked.EmoteUrn.IsNullOrEmpty(),
81+
"A one-shot masked emote that already played must not stay resumable: while its urn is set, every frame that meets the play conditions starts the emote again.");
82+
Assert.IsNull(masked.CurrentEmoteReference);
83+
Assert.IsFalse(globalWorld.Has<EmotePendingToBroadcast>(globalPlayerEntity),
84+
"Nothing was played, so nothing must be broadcast.");
85+
}
86+
87+
[Test]
88+
public void KeepLoopingEmoteResumable()
89+
{
90+
Entity entity = CreateSuspendedMaskedEmote(loop: true);
91+
92+
system!.Update(0);
93+
94+
CharacterMaskedEmoteComponent masked = world.Get<CharacterMaskedEmoteComponent>(entity);
95+
96+
Assert.IsFalse(masked.EmoteUrn.IsNullOrEmpty(),
97+
"A looping masked emote is resumed once the play conditions are met again, so its urn must survive being suspended.");
98+
}
99+
100+
/// <summary>
101+
/// A masked emote in the state a non-permanent stop leaves behind: the animation is torn down
102+
/// but the urn is kept so that the emote can be resumed.
103+
/// </summary>
104+
private Entity CreateSuspendedMaskedEmote(bool loop)
105+
{
106+
IEmote emote = Substitute.For<IEmote>();
107+
emote.IsLoading.Returns(false);
108+
emote.IsLooping().Returns(loop);
109+
110+
// Empty results: the asset is not resident, so playback stops short of touching the avatar view.
111+
emote.AssetResults.Returns(new StreamableLoadingResult<AttachmentRegularAsset>?[BodyShape.COUNT]);
112+
113+
emoteStorage.TryGetElement(Arg.Any<URN>(), out Arg.Any<IEmote>())
114+
.Returns(call =>
115+
{
116+
call[1] = emote;
117+
return true;
118+
});
119+
120+
return world.Create(new CharacterMaskedEmoteComponent
121+
{
122+
EmoteUrn = string.Format(EMOTE_URN_FORMAT, loop.ToString().ToLower()),
123+
Mask = AvatarEmoteMask.AemUpperBody,
124+
});
125+
}
126+
}
127+
}

Explorer/Assets/DCL/AvatarRendering/Emotes/Tests/SceneMaskedEmoteSystemShould.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Explorer/Assets/DCL/Character/CharacterMotion/Systems/TeleportPositionCalculationSystem.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,8 @@ private void CalculateTeleportPosition(in Entity playerEntity, ref PlayerTelepor
5858
// Aim at the parcel center: its base corner lies on the parcel boundary, where settling
5959
// tips the avatar into the neighbouring parcel. The exact landing XZ is refined later by
6060
// TeleportCharacterSystem, which probes the parcel for its actual walkable floor.
61-
const float HALF_PARCEL_SIZE = ParcelMathHelper.PARCEL_SIZE / 2f;
6261
Vector3 targetWorldPosition = ParcelMathHelper.GetPositionByParcelPosition(parcel)
63-
+ new Vector3(HALF_PARCEL_SIZE, 0f, HALF_PARCEL_SIZE);
62+
+ new Vector3(ParcelMathHelper.HALF_PARCEL_SIZE, 0f, ParcelMathHelper.HALF_PARCEL_SIZE);
6463

6564
// Keep the landing inside the scene's parcels; if it falls outside, ValidateTeleportPosition
6665
// clamps it to the requested parcel's base position.

0 commit comments

Comments
 (0)