diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs index 71ed0a14a2..5dc553b9df 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs @@ -1,26 +1,109 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using Moq; +using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.Notifications.WebSocket; +using osu.Game.Online.Notifications.WebSocket.Events; using osu.Game.Overlays; -using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public partial class TestSceneMedalOverlay : OsuTestScene + public partial class TestSceneMedalOverlay : OsuManualInputManagerTestScene { - public TestSceneMedalOverlay() + private readonly Bindable overlayActivationMode = new Bindable(OverlayActivation.All); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private MedalOverlay overlay = null!; + + [SetUpSteps] + public void SetUpSteps() { - AddStep(@"display", () => + var overlayManagerMock = new Mock(); + overlayManagerMock.Setup(mock => mock.OverlayActivationMode).Returns(overlayActivationMode); + + AddStep("create overlay", () => Child = new DependencyProvidingContainer { - LoadComponentAsync(new MedalOverlay(new Medal - { - Name = @"Animations", - InternalName = @"all-intro-doubletime", - Description = @"More complex than you think.", - }), Add); + Child = overlay = new MedalOverlay(), + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(IOverlayManager), overlayManagerMock.Object) + ] }); } + + [Test] + public void TestBasicAward() + { + awardMedal(new UserAchievementUnlock + { + Title = "Time And A Half", + Description = "Having a right ol' time. One and a half of them, almost.", + Slug = @"all-intro-doubletime" + }); + AddUntilStep("overlay shown", () => overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("wait for load", () => this.ChildrenOfType().Any()); + AddRepeatStep("dismiss", () => InputManager.Key(Key.Escape), 2); + AddUntilStep("overlay hidden", () => overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestMultipleMedalsInQuickSuccession() + { + awardMedal(new UserAchievementUnlock + { + Title = "Time And A Half", + Description = "Having a right ol' time. One and a half of them, almost.", + Slug = @"all-intro-doubletime" + }); + awardMedal(new UserAchievementUnlock + { + Title = "S-Ranker", + Description = "Accuracy is really underrated.", + Slug = @"all-secret-rank-s" + }); + awardMedal(new UserAchievementUnlock + { + Title = "500 Combo", + Description = "500 big ones! You're moving up in the world!", + Slug = @"osu-combo-500" + }); + } + + [Test] + public void TestDelayMedalDisplayUntilActivationModeAllowsIt() + { + AddStep("disable overlay activation", () => overlayActivationMode.Value = OverlayActivation.Disabled); + awardMedal(new UserAchievementUnlock + { + Title = "Time And A Half", + Description = "Having a right ol' time. One and a half of them, almost.", + Slug = @"all-intro-doubletime" + }); + AddUntilStep("overlay hidden", () => overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("re-enable overlay activation", () => overlayActivationMode.Value = OverlayActivation.All); + AddUntilStep("overlay shown", () => overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + private void awardMedal(UserAchievementUnlock unlock) => AddStep("award medal", () => dummyAPI.NotificationsClient.Receive(new SocketMessage + { + Event = @"new", + Data = JObject.FromObject(new NewPrivateNotificationEvent + { + Name = @"user_achievement_unlock", + Details = JObject.FromObject(unlock) + }) + })); } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 7e42d4781d..0fa2fd4b0b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -6,6 +6,7 @@ using System; using System.IO; using System.Linq; +using Newtonsoft.Json.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; @@ -24,6 +25,8 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Leaderboards; +using osu.Game.Online.Notifications.WebSocket; +using osu.Game.Online.Notifications.WebSocket.Events; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.Mods; @@ -340,6 +343,28 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for results", () => Game.ScreenStack.CurrentScreen is ResultsScreen); } + [Test] + public void TestShowMedalAtResults() + { + playToResults(); + + AddStep("award medal", () => ((DummyAPIAccess)API).NotificationsClient.Receive(new SocketMessage + { + Event = @"new", + Data = JObject.FromObject(new NewPrivateNotificationEvent + { + Name = @"user_achievement_unlock", + Details = JObject.FromObject(new UserAchievementUnlock + { + Title = "Time And A Half", + Description = "Having a right ol' time. One and a half of them, almost.", + Slug = @"all-intro-doubletime" + }) + }) + })); + AddUntilStep("medal overlay shown", () => Game.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + [Test] public void TestRetryFromResults() { diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 162c4b6a59..1945b2f0dd 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -20,10 +18,10 @@ namespace osu.Game.Graphics.Containers [Cached(typeof(IPreviewTrackOwner))] public abstract partial class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { - private Sample samplePopIn; - private Sample samplePopOut; - protected virtual string PopInSampleName => "UI/overlay-pop-in"; - protected virtual string PopOutSampleName => "UI/overlay-pop-out"; + protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); + + protected virtual string? PopInSampleName => @"UI/overlay-pop-in"; + protected virtual string? PopOutSampleName => @"UI/overlay-pop-out"; protected virtual double PopInOutSampleBalance => 0; protected override bool BlockNonPositionalInput => true; @@ -34,19 +32,23 @@ namespace osu.Game.Graphics.Containers /// protected virtual bool DimMainContent => true; - [Resolved(CanBeNull = true)] - private IOverlayManager overlayManager { get; set; } + [Resolved] + private IOverlayManager? overlayManager { get; set; } [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } + private PreviewTrackManager previewTrackManager { get; set; } = null!; - protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); + private Sample? samplePopIn; + private Sample? samplePopOut; - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio) + [BackgroundDependencyLoader] + private void load(AudioManager? audio) { - samplePopIn = audio.Samples.Get(PopInSampleName); - samplePopOut = audio.Samples.Get(PopOutSampleName); + if (!string.IsNullOrEmpty(PopInSampleName)) + samplePopIn = audio?.Samples.Get(PopInSampleName); + + if (!string.IsNullOrEmpty(PopOutSampleName)) + samplePopOut = audio?.Samples.Get(PopOutSampleName); } protected override void LoadComplete() diff --git a/osu.Game/Online/Chat/WebSocketChatClient.cs b/osu.Game/Online/Chat/WebSocketChatClient.cs index 8e1b501b25..37774a1f5d 100644 --- a/osu.Game/Online/Chat/WebSocketChatClient.cs +++ b/osu.Game/Online/Chat/WebSocketChatClient.cs @@ -13,6 +13,8 @@ using osu.Framework.Logging; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Notifications.WebSocket; +using osu.Game.Online.Notifications.WebSocket.Events; +using osu.Game.Online.Notifications.WebSocket.Requests; namespace osu.Game.Online.Chat { diff --git a/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs b/osu.Game/Online/Notifications/WebSocket/Events/NewChatMessageData.cs similarity index 94% rename from osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs rename to osu.Game/Online/Notifications/WebSocket/Events/NewChatMessageData.cs index 850fbd226b..ff9f5ee9f7 100644 --- a/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs +++ b/osu.Game/Online/Notifications/WebSocket/Events/NewChatMessageData.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; -namespace osu.Game.Online.Notifications.WebSocket +namespace osu.Game.Online.Notifications.WebSocket.Events { /// /// A websocket message sent from the server when new messages arrive. diff --git a/osu.Game/Online/Notifications/WebSocket/Events/NewPrivateNotificationEvent.cs b/osu.Game/Online/Notifications/WebSocket/Events/NewPrivateNotificationEvent.cs new file mode 100644 index 0000000000..1fc9636136 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/Events/NewPrivateNotificationEvent.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Notifications.WebSocket.Events +{ + /// + /// Reference: https://github.com/ppy/osu-web/blob/master/app/Events/NewPrivateNotificationEvent.php + /// + public class NewPrivateNotificationEvent + { + [JsonProperty("id")] + public ulong ID { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonProperty("object_type")] + public string ObjectType { get; set; } = string.Empty; + + [JsonProperty("object_id")] + public ulong ObjectId { get; set; } + + [JsonProperty("source_user_id")] + public uint SourceUserID { get; set; } + + [JsonProperty("is_read")] + public bool IsRead { get; set; } + + [JsonProperty("details")] + public JObject? Details { get; set; } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/Events/UserAchievementUnlock.cs b/osu.Game/Online/Notifications/WebSocket/Events/UserAchievementUnlock.cs new file mode 100644 index 0000000000..6c7c8af4f4 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/Events/UserAchievementUnlock.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.Notifications.WebSocket.Events +{ + /// + /// Reference: https://github.com/ppy/osu-web/blob/master/app/Jobs/Notifications/UserAchievementUnlock.php + /// + public class UserAchievementUnlock + { + [JsonProperty("achievement_id")] + public uint AchievementId { get; set; } + + [JsonProperty("achievement_mode")] + public ushort? AchievementMode { get; set; } + + [JsonProperty("cover_url")] + public string CoverUrl { get; set; } = string.Empty; + + [JsonProperty("slug")] + public string Slug { get; set; } = string.Empty; + + [JsonProperty("title")] + public string Title { get; set; } = string.Empty; + + [JsonProperty("description")] + public string Description { get; set; } = string.Empty; + + [JsonProperty("user_id")] + public uint UserId { get; set; } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/Requests/EndChatRequest.cs similarity index 89% rename from osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs rename to osu.Game/Online/Notifications/WebSocket/Requests/EndChatRequest.cs index 7f67587f5d..9058fea815 100644 --- a/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs +++ b/osu.Game/Online/Notifications/WebSocket/Requests/EndChatRequest.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; -namespace osu.Game.Online.Notifications.WebSocket +namespace osu.Game.Online.Notifications.WebSocket.Requests { /// /// A websocket message notifying the server that the client no longer wants to receive chat messages. diff --git a/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/Requests/StartChatRequest.cs similarity index 89% rename from osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs rename to osu.Game/Online/Notifications/WebSocket/Requests/StartChatRequest.cs index 9dd69a7377..bc96415642 100644 --- a/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs +++ b/osu.Game/Online/Notifications/WebSocket/Requests/StartChatRequest.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; -namespace osu.Game.Online.Notifications.WebSocket +namespace osu.Game.Online.Notifications.WebSocket.Requests { /// /// A websocket message notifying the server that the client wants to receive chat messages. diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index eb1219f183..732d5f867c 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1083,6 +1083,7 @@ namespace osu.Game loadComponentSingleFile(new AccountCreationOverlay(), topMostOverlayContent.Add, true); loadComponentSingleFile(new DialogOverlay(), topMostOverlayContent.Add, true); + loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); diff --git a/osu.Game/Overlays/MedalAnimation.cs b/osu.Game/Overlays/MedalAnimation.cs new file mode 100644 index 0000000000..25776d50db --- /dev/null +++ b/osu.Game/Overlays/MedalAnimation.cs @@ -0,0 +1,312 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; +using osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osu.Game.Users; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Overlays.MedalSplash; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Shapes; +using System; +using System.Diagnostics; +using osu.Framework.Graphics.Effects; +using osu.Framework.Utils; + +namespace osu.Game.Overlays +{ + public partial class MedalAnimation : VisibilityContainer + { + public const float DISC_SIZE = 400; + + private const float border_width = 5; + + private readonly Medal medal; + private readonly Box background; + private readonly Container backgroundStrip, particleContainer; + private readonly BackgroundStrip leftStrip, rightStrip; + private readonly CircularContainer disc; + private readonly Sprite innerSpin, outerSpin; + + private DrawableMedal? drawableMedal; + private Sample? getSample; + + private readonly Container content; + + public MedalAnimation(Medal medal) + { + this.medal = medal; + RelativeSizeAxes = Axes.Both; + + Child = content = new Container + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(60), + }, + outerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(DISC_SIZE + 500), + Alpha = 0f, + }, + backgroundStrip = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = border_width, + Alpha = 0f, + Children = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Width = 0.5f, + Padding = new MarginPadding { Right = DISC_SIZE / 2 }, + Children = new[] + { + leftStrip = new BackgroundStrip(0f, 1f) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Width = 0.5f, + Padding = new MarginPadding { Left = DISC_SIZE / 2 }, + Children = new[] + { + rightStrip = new BackgroundStrip(1f, 0f), + }, + }, + }, + }, + particleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + }, + disc = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0f, + Masking = true, + AlwaysPresent = true, + BorderColour = Color4.White, + BorderThickness = border_width, + Size = new Vector2(DISC_SIZE), + Scale = new Vector2(0.8f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"05262f"), + }, + new Triangles + { + RelativeSizeAxes = Axes.Both, + TriangleScale = 2, + ColourDark = Color4Extensions.FromHex(@"04222b"), + ColourLight = Color4Extensions.FromHex(@"052933"), + }, + innerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1.05f), + Alpha = 0.25f, + }, + }, + }, + } + }; + + Show(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, TextureStore textures, AudioManager audio) + { + getSample = audio.Samples.Get(@"MedalSplash/medal-get"); + innerSpin.Texture = outerSpin.Texture = textures.Get(@"MedalSplash/disc-spin"); + + disc.EdgeEffect = leftStrip.EdgeEffect = rightStrip.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colours.Blue.Opacity(0.5f), + Radius = 50, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LoadComponentAsync(drawableMedal = new DrawableMedal(medal) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + }, loaded => + { + disc.Add(loaded); + startAnimation(); + }); + } + + protected override void Update() + { + base.Update(); + + particleContainer.Add(new MedalParticle(RNG.Next(0, 359))); + } + + private const double initial_duration = 400; + private const double step_duration = 900; + + private void startAnimation() + { + content.Show(); + + background.FlashColour(Color4.White.Opacity(0.25f), 400); + + getSample?.Play(); + + innerSpin.Spin(20000, RotationDirection.Clockwise); + outerSpin.Spin(40000, RotationDirection.Clockwise); + + using (BeginDelayedSequence(200)) + { + disc.FadeIn(initial_duration) + .ScaleTo(1f, initial_duration * 2, Easing.OutElastic); + + particleContainer.FadeIn(initial_duration); + outerSpin.FadeTo(0.1f, initial_duration * 2); + + using (BeginDelayedSequence(initial_duration + 200)) + { + backgroundStrip.FadeIn(step_duration); + leftStrip.ResizeWidthTo(1f, step_duration, Easing.OutQuint); + rightStrip.ResizeWidthTo(1f, step_duration, Easing.OutQuint); + + Debug.Assert(drawableMedal != null); + + this.Animate().Schedule(() => + { + if (drawableMedal.State != DisplayState.Full) + drawableMedal.State = DisplayState.Icon; + }).Delay(step_duration).Schedule(() => + { + if (drawableMedal.State != DisplayState.Full) + drawableMedal.State = DisplayState.MedalUnlocked; + }).Delay(step_duration).Schedule(() => + { + if (drawableMedal.State != DisplayState.Full) + drawableMedal.State = DisplayState.Full; + }); + } + } + } + + protected override void PopIn() + { + this.FadeIn(200); + } + + protected override void PopOut() + { + this.FadeOut(200); + } + + public void Dismiss() + { + if (drawableMedal != null && drawableMedal.State != DisplayState.Full) + { + // if we haven't yet, play out the animation fully + drawableMedal.State = DisplayState.Full; + FinishTransforms(true); + return; + } + + Hide(); + Expire(); + } + + private partial class BackgroundStrip : Container + { + public BackgroundStrip(float start, float end) + { + RelativeSizeAxes = Axes.Both; + Width = 0f; + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(start), Color4.White.Opacity(end)); + Masking = true; + + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + } + }; + } + } + + private partial class MedalParticle : CircularContainer + { + private readonly float direction; + + private Vector2 positionForOffset(float offset) => new Vector2((float)(offset * Math.Sin(direction)), (float)(offset * Math.Cos(direction))); + + public MedalParticle(float direction) + { + this.direction = direction; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Position = positionForOffset(DISC_SIZE / 2); + Masking = true; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colours.Blue.Opacity(0.5f), + Radius = 5, + }; + + this.MoveTo(positionForOffset(DISC_SIZE / 2 + 200), 500); + this.FadeOut(500); + Expire(); + } + } + } +} diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index eba35ec6f9..072d7db6c7 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -1,324 +1,130 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Sprites; -using osu.Game.Users; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Overlays.MedalSplash; -using osu.Framework.Allocation; -using osu.Framework.Audio.Sample; -using osu.Framework.Audio; -using osu.Framework.Graphics.Textures; -using osuTK.Input; -using osu.Framework.Graphics.Shapes; -using System; -using osu.Framework.Graphics.Effects; using osu.Framework.Input.Events; -using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Online.Notifications.WebSocket; +using osu.Game.Online.Notifications.WebSocket.Events; +using osu.Game.Users; namespace osu.Game.Overlays { - public partial class MedalOverlay : FocusedOverlayContainer + public partial class MedalOverlay : OsuFocusedOverlayContainer { - public const float DISC_SIZE = 400; + protected override string? PopInSampleName => null; + protected override string? PopOutSampleName => null; - private const float border_width = 5; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - private readonly Medal medal; - private readonly Box background; - private readonly Container backgroundStrip, particleContainer; - private readonly BackgroundStrip leftStrip, rightStrip; - private readonly CircularContainer disc; - private readonly Sprite innerSpin, outerSpin; - private DrawableMedal drawableMedal; + protected override void PopIn() => this.FadeIn(); - private Sample getSample; + protected override void PopOut() => this.FadeOut(); - private readonly Container content; + private readonly Queue queuedMedals = new Queue(); - public MedalOverlay(Medal medal) - { - this.medal = medal; - RelativeSizeAxes = Axes.Both; + [Resolved] + private IAPIProvider api { get; set; } = null!; - Child = content = new Container - { - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(60), - }, - outerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(DISC_SIZE + 500), - Alpha = 0f, - }, - backgroundStrip = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = border_width, - Alpha = 0f, - Children = new[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, - Width = 0.5f, - Padding = new MarginPadding { Right = DISC_SIZE / 2 }, - Children = new[] - { - leftStrip = new BackgroundStrip(0f, 1f) - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - }, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - Width = 0.5f, - Padding = new MarginPadding { Left = DISC_SIZE / 2 }, - Children = new[] - { - rightStrip = new BackgroundStrip(1f, 0f), - }, - }, - }, - }, - particleContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0f, - }, - disc = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0f, - Masking = true, - AlwaysPresent = true, - BorderColour = Color4.White, - BorderThickness = border_width, - Size = new Vector2(DISC_SIZE), - Scale = new Vector2(0.8f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"05262f"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - TriangleScale = 2, - ColourDark = Color4Extensions.FromHex(@"04222b"), - ColourLight = Color4Extensions.FromHex(@"052933"), - }, - innerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1.05f), - Alpha = 0.25f, - }, - }, - }, - } - }; - - Show(); - } + private Container medalContainer = null!; + private MedalAnimation? lastAnimation; [BackgroundDependencyLoader] - private void load(OsuColour colours, TextureStore textures, AudioManager audio) + private void load() { - getSample = audio.Samples.Get(@"MedalSplash/medal-get"); - innerSpin.Texture = outerSpin.Texture = textures.Get(@"MedalSplash/disc-spin"); + RelativeSizeAxes = Axes.Both; - disc.EdgeEffect = leftStrip.EdgeEffect = rightStrip.EdgeEffect = new EdgeEffectParameters + api.NotificationsClient.MessageReceived += handleMedalMessages; + + Add(medalContainer = new Container { - Type = EdgeEffectType.Glow, - Colour = colours.Blue.Opacity(0.5f), - Radius = 50, - }; + RelativeSizeAxes = Axes.Both + }); } protected override void LoadComplete() { base.LoadComplete(); - LoadComponentAsync(drawableMedal = new DrawableMedal(medal) + OverlayActivationMode.BindValueChanged(val => { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - }, loaded => + if (val.NewValue == OverlayActivation.All && (queuedMedals.Any() || medalContainer.Any() || lastAnimation?.IsLoaded == false)) + Show(); + }, true); + } + + private void handleMedalMessages(SocketMessage obj) + { + if (obj.Event != @"new") + return; + + var data = obj.Data?.ToObject(); + if (data == null || data.Name != @"user_achievement_unlock") + return; + + var details = data.Details?.ToObject(); + if (details == null) + return; + + var medal = new Medal { - disc.Add(loaded); - startAnimation(); - }); + Name = details.Title, + InternalName = details.Slug, + Description = details.Description, + }; + + var medalAnimation = new MedalAnimation(medal); + queuedMedals.Enqueue(medalAnimation); + if (OverlayActivationMode.Value == OverlayActivation.All) + Scheduler.AddOnce(Show); } protected override void Update() { base.Update(); - particleContainer.Add(new MedalParticle(RNG.Next(0, 359))); + if (medalContainer.Any() || lastAnimation?.IsLoaded == false) + return; + + if (!queuedMedals.TryDequeue(out lastAnimation)) + { + Hide(); + return; + } + + LoadComponentAsync(lastAnimation, medalContainer.Add); } protected override bool OnClick(ClickEvent e) { - dismiss(); + lastAnimation?.Dismiss(); return true; } - protected override void OnFocusLost(FocusLostEvent e) + public override bool OnPressed(KeyBindingPressEvent e) { - if (e.CurrentState.Keyboard.Keys.IsPressed(Key.Escape)) dismiss(); - } - - private const double initial_duration = 400; - private const double step_duration = 900; - - private void startAnimation() - { - content.Show(); - - background.FlashColour(Color4.White.Opacity(0.25f), 400); - - getSample.Play(); - - innerSpin.Spin(20000, RotationDirection.Clockwise); - outerSpin.Spin(40000, RotationDirection.Clockwise); - - using (BeginDelayedSequence(200)) + if (e.Action == GlobalAction.Back) { - disc.FadeIn(initial_duration) - .ScaleTo(1f, initial_duration * 2, Easing.OutElastic); - - particleContainer.FadeIn(initial_duration); - outerSpin.FadeTo(0.1f, initial_duration * 2); - - using (BeginDelayedSequence(initial_duration + 200)) - { - backgroundStrip.FadeIn(step_duration); - leftStrip.ResizeWidthTo(1f, step_duration, Easing.OutQuint); - rightStrip.ResizeWidthTo(1f, step_duration, Easing.OutQuint); - - this.Animate().Schedule(() => - { - if (drawableMedal.State != DisplayState.Full) - drawableMedal.State = DisplayState.Icon; - }).Delay(step_duration).Schedule(() => - { - if (drawableMedal.State != DisplayState.Full) - drawableMedal.State = DisplayState.MedalUnlocked; - }).Delay(step_duration).Schedule(() => - { - if (drawableMedal.State != DisplayState.Full) - drawableMedal.State = DisplayState.Full; - }); - } - } - } - - protected override void PopIn() - { - this.FadeIn(200); - } - - protected override void PopOut() - { - this.FadeOut(200); - } - - private void dismiss() - { - if (drawableMedal.State != DisplayState.Full) - { - // if we haven't yet, play out the animation fully - drawableMedal.State = DisplayState.Full; - FinishTransforms(true); - return; + lastAnimation?.Dismiss(); + return true; } - Hide(); - Expire(); + return base.OnPressed(e); } - private partial class BackgroundStrip : Container + protected override void Dispose(bool isDisposing) { - public BackgroundStrip(float start, float end) - { - RelativeSizeAxes = Axes.Both; - Width = 0f; - Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(start), Color4.White.Opacity(end)); - Masking = true; + base.Dispose(isDisposing); - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - } - }; - } - } - - private partial class MedalParticle : CircularContainer - { - private readonly float direction; - - private Vector2 positionForOffset(float offset) => new Vector2((float)(offset * Math.Sin(direction)), (float)(offset * Math.Cos(direction))); - - public MedalParticle(float direction) - { - this.direction = direction; - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - Position = positionForOffset(DISC_SIZE / 2); - Masking = true; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colours.Blue.Opacity(0.5f), - Radius = 5, - }; - - this.MoveTo(positionForOffset(DISC_SIZE / 2 + 200), 500); - this.FadeOut(500); - Expire(); - } + if (api.IsNotNull()) + api.NotificationsClient.MessageReceived -= handleMedalMessages; } } } diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index f4f6fd2bc1..2beed6645a 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -38,7 +38,7 @@ namespace osu.Game.Overlays.MedalSplash public DrawableMedal(Medal medal) { this.medal = medal; - Position = new Vector2(0f, MedalOverlay.DISC_SIZE / 2); + Position = new Vector2(0f, MedalAnimation.DISC_SIZE / 2); FillFlowContainer infoFlow; Children = new Drawable[] @@ -174,7 +174,7 @@ namespace osu.Game.Overlays.MedalSplash .ScaleTo(1); this.ScaleTo(scale_when_unlocked, duration, Easing.OutExpo); - this.MoveToY(MedalOverlay.DISC_SIZE / 2 - 30, duration, Easing.OutExpo); + this.MoveToY(MedalAnimation.DISC_SIZE / 2 - 30, duration, Easing.OutExpo); unlocked.FadeInFromZero(duration); break; @@ -184,7 +184,7 @@ namespace osu.Game.Overlays.MedalSplash .ScaleTo(1); this.ScaleTo(scale_when_full, duration, Easing.OutExpo); - this.MoveToY(MedalOverlay.DISC_SIZE / 2 - 60, duration, Easing.OutExpo); + this.MoveToY(MedalAnimation.DISC_SIZE / 2 - 60, duration, Easing.OutExpo); unlocked.Show(); name.FadeInFromZero(duration + 100); description.FadeInFromZero(duration * 2); diff --git a/osu.Game/Properties/AssemblyInfo.cs b/osu.Game/Properties/AssemblyInfo.cs index 1b77e45891..be430a0fe4 100644 --- a/osu.Game/Properties/AssemblyInfo.cs +++ b/osu.Game/Properties/AssemblyInfo.cs @@ -11,3 +11,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("osu.Game.Tests.Dynamic")] [assembly: InternalsVisibleTo("osu.Game.Tests.iOS")] [assembly: InternalsVisibleTo("osu.Game.Tests.Android")] + +// intended for Moq usage +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index 83b02a0951..36c70dff28 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -31,6 +31,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// public partial class AccuracyCircle : CompositeDrawable { + /// + /// The total duration of the animation. + /// + public const double TOTAL_DURATION = APPEAR_DURATION + ACCURACY_TRANSFORM_DELAY + ACCURACY_TRANSFORM_DURATION; + /// /// Duration for the transforms causing this component to appear. /// diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index b63c753721..e579c3fe51 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -25,8 +25,10 @@ using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Placeholders; +using osu.Game.Overlays; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; using osuTK; @@ -41,6 +43,8 @@ namespace osu.Game.Screens.Ranking public override bool? AllowGlobalTrackControl => true; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; + public readonly Bindable SelectedScore = new Bindable(); [CanBeNull] @@ -172,6 +176,10 @@ namespace osu.Game.Screens.Ranking bool shouldFlair = player != null && !Score.User.IsBot; ScorePanelList.AddScore(Score, shouldFlair); + // this is mostly for medal display. + // we don't want the medal animation to trample on the results screen animation, so we (ab)use `OverlayActivationMode` + // to give the results screen enough time to play the animation out before the medals can be shown. + Scheduler.AddDelayed(() => OverlayActivationMode.Value = OverlayActivation.All, shouldFlair ? AccuracyCircle.TOTAL_DURATION + 1000 : 0); } if (AllowWatchingReplay)