diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCursorContainer.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCursorContainer.cs new file mode 100644 index 000000000000..7b848222fe26 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCursorContainer.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingCursorContainer : MultiplayerTestScene + { + private readonly IReadOnlyList users = new[] + { + new APIUser + { + Id = 2, + Username = "peppy", + }, + new APIUser + { + Id = 1040328, + Username = "smoogipoo", + }, + new APIUser + { + Id = 6573093, + Username = "OliBomby", + }, + new APIUser + { + Id = 7782553, + Username = "aesth", + }, + new APIUser + { + Id = 6411631, + Username = "Maarvin", + } + }; + + private readonly PlaylistItem[] items = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + private MatchmakingCursorContainer? cursorContainer; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.Matchmaking); + room.Playlist = items; + + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("add users", () => + { + foreach (var user in users) + MultiplayerClient.AddUser(user); + }); + + AddStep("add screen", () => + { + Child = new ScreenStack(new SubScreenBeatmapSelect()); + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var inputManager = GetContainingInputManager()!; + + Scheduler.AddDelayed(() => + { + cursorContainer ??= this.ChildrenOfType().FirstOrDefault(); + + if (cursorContainer == null) + return; + + var position = cursorContainer.ToLocalSpace(inputManager.CurrentState.Mouse.Position); + + var request = new MatchmakingCursorPositionRequest + { + X = position.X, + Y = position.Y, + }; + + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(users[0].Id, request).FireAndForget(); + }, 500); + + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(users[1].Id, request).FireAndForget(); + }, 1000); + + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(users[2].Id, request).FireAndForget(); + }, 1500); + }, 50, true); + } + } +} diff --git a/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionEvent.cs b/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionEvent.cs new file mode 100644 index 000000000000..7830add0fb53 --- /dev/null +++ b/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionEvent.cs @@ -0,0 +1,35 @@ +// 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 MessagePack; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Online.Matchmaking.Events +{ + /// + /// Requests to perform update a user's cursor position in a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingCursorPositionEvent : MatchServerEvent + { + /// + /// The user performing the action. + /// + [Key(0)] + public int UserId { get; set; } + + /// + /// The cursor's x position. + /// + [Key(1)] + public float X { get; set; } + + /// + /// The cursor's x position. + /// + [Key(2)] + public float Y { get; set; } + } +} diff --git a/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionRequest.cs b/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionRequest.cs new file mode 100644 index 000000000000..608358e68c2b --- /dev/null +++ b/osu.Game/Online/Matchmaking/Events/MatchmakingCursorPositionRequest.cs @@ -0,0 +1,29 @@ +// 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 MessagePack; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Online.Matchmaking.Events +{ + /// + /// Requests to perform update a user's cursor position in a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingCursorPositionRequest : MatchUserRequest + { + /// + /// The cursor's x position. + /// + [Key(0)] + public float X { get; set; } + + /// + /// The cursor's x position. + /// + [Key(1)] + public float Y { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 529a2994388f..58eb00b97db2 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -17,6 +17,7 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(CountdownStartedEvent))] [Union(1, typeof(CountdownStoppedEvent))] [Union(2, typeof(MatchmakingAvatarActionEvent))] + [Union(3, typeof(MatchmakingCursorPositionEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index 02704ea161d4..04c05d895820 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -19,6 +19,7 @@ namespace osu.Game.Online.Multiplayer [Union(1, typeof(StartMatchCountdownRequest))] [Union(2, typeof(StopCountdownRequest))] [Union(3, typeof(MatchmakingAvatarActionRequest))] + [Union(4, typeof(MatchmakingCursorPositionRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index e50989148657..0874990bfa64 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -57,6 +57,8 @@ internal static class SignalRWorkaroundTypes (typeof(MatchmakingStageCountdown), typeof(MultiplayerCountdown)), (typeof(MatchmakingAvatarActionRequest), typeof(MatchUserRequest)), (typeof(MatchmakingAvatarActionEvent), typeof(MatchServerEvent)), + (typeof(MatchmakingCursorPositionRequest), typeof(MatchUserRequest)), + (typeof(MatchmakingCursorPositionEvent), typeof(MatchServerEvent)), }; } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs index 1d3153915f44..c0acbe8360e8 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs @@ -55,12 +55,19 @@ public BeatmapSelectGrid() { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, - Child = panelGridContainer = new PanelGridContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(20), - Spacing = new Vector2(panel_spacing) + panelGridContainer = new PanelGridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(20), + Spacing = new Vector2(panel_spacing) + }, + new MatchmakingCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, }, }, rollContainer = new Container diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingCursorContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingCursorContainer.cs new file mode 100644 index 000000000000..b1841882c869 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingCursorContainer.cs @@ -0,0 +1,184 @@ +// 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 System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Multiplayer; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingCursorContainer : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private Vector2? latestMousePosition; + + private readonly Dictionary cursorLookup = new Dictionary(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.UserJoined += onUserJoined; + client.UserLeft += onUserLeft; + client.MatchEvent += onMatchEvent; + + if (client.Room != null) + { + foreach (var user in client.Room.Users) + onUserJoined(user); + } + + Scheduler.AddDelayed(updateMousePosition, 50, repeat: true); + } + + private void onMatchEvent(MatchServerEvent evt) + { + if (evt is MatchmakingCursorPositionEvent cursorEvent) + cursorLookup.GetValueOrDefault(cursorEvent.UserId)?.OnCursorPositionReceived(new Vector2(cursorEvent.X, cursorEvent.Y)); + } + + private void updateMousePosition() + { + if (latestMousePosition is { } pos) + { + client.SendMatchRequest(new MatchmakingCursorPositionRequest + { + X = pos.X, + Y = pos.Y, + }); + + latestMousePosition = null; + } + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + latestMousePosition = e.MousePosition; + + return false; + } + + private void onUserJoined(MultiplayerRoomUser user) + { + if (user.UserID == api.LocalUser.Value?.Id) + return; + + if (cursorLookup.ContainsKey(user.UserID)) + return; + + var cursor = new Cursor(user.User!); + cursorLookup[user.UserID] = cursor; + AddInternal(cursor); + } + + private void onUserLeft(MultiplayerRoomUser user) + { + if (cursorLookup.Remove(user.UserID, out var cursor)) + cursor.Expire(); + } + + protected override void Dispose(bool isDisposing) + { + if (client.IsNotNull()) + { + client.UserJoined -= onUserJoined; + client.UserLeft -= onUserLeft; + client.MatchEvent -= onMatchEvent; + } + + base.Dispose(isDisposing); + } + + private partial class Cursor : CompositeDrawable + { + private float targetAlpha = 0; + + private readonly APIUser user; + + public Cursor(APIUser user) + { + this.user = user; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Alpha = 0; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + + InternalChildren = new Drawable[] + { + new Sprite + { + Texture = textures.Get(@"Cursor/menu-cursor"), + Scale = new Vector2(0.1f) + }, + new CircularContainer + { + Position = new Vector2(20, -15), + Size = new Vector2(20), + Masking = true, + Child = new UpdateableAvatar(user) + { + RelativeSizeAxes = Axes.Both, + } + } + }; + } + + public void OnCursorPositionReceived(Vector2 position) + { + ClearTransforms(targetMember: nameof(targetAlpha)); + + this.MoveTo(position, 100, Easing.Out); + + this.TransformTo(nameof(targetAlpha), 1f, 100) + .Delay(1000) + .TransformTo(nameof(targetAlpha), 0f, 4000); + } + + protected override void Update() + { + base.Update(); + + const float fade_distance = 50f; + + var parentRect = new RectangleF(Parent!.ChildOffset, Parent!.ChildSize).Shrink(fade_distance * 2); + + var position = DrawPosition; + + float distance = 0; + + if (position.X < parentRect.Left) + distance = Math.Max(distance, parentRect.Left - position.X); + if (position.Y < parentRect.Top) + distance = Math.Max(distance, parentRect.Top - position.Y); + if (position.X > parentRect.Right) + distance = Math.Max(distance, position.X - parentRect.Right); + if (position.Y > parentRect.Bottom) + distance = Math.Max(distance, position.Y - parentRect.Bottom); + + Alpha = float.Clamp(1 - distance / fade_distance, 0, 1); + } + } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 5b2876a98977..ac689e66b9fa 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -428,6 +428,15 @@ public async Task SendUserMatchRequest(int userId, MatchUserRequest request) Action = avatarAction.Action }).ConfigureAwait(false); break; + + case MatchmakingCursorPositionRequest cursorAction: + await ((IMultiplayerClient)this).MatchEvent(new MatchmakingCursorPositionEvent + { + UserId = userId, + X = cursorAction.X, + Y = cursorAction.Y, + }).ConfigureAwait(false); + break; } }