From c4a49f647f7ff7551a7d03ee6568847f957fbd70 Mon Sep 17 00:00:00 2001 From: maarvin Date: Wed, 1 Apr 2026 11:21:44 +0200 Subject: [PATCH] Ranked Play: Make cards draggable and reorderable (#37157) Adds the ability to drag and reorder cards. Card order is preserved between rounds and is synchronized between both players (each player can see the other player drag around and reorder their cards). To make this possible I had to rewrite the card layout algorithm to be stateless (e0a46fefaf0f234370132d055e2edd7be069b242), there wasn't really a way around that since I needed a way to calculate a layout position based on a card's index. Should hopefully be a lot easier to read now though. Some noteworthy stuff: - I didn't really know what the best place to store the card order is, so I put it on `RankedPlayCardWithPlaylistItem` since that one will stay the same instance per round. - To prevent the opponent's cards from dragged into the middle of the playfield, only the x axis of the drag gets synchronized for the `OpponentHandOfCards` with a fixed y value. - I adjusted the replay recorder/player parameters a little. With the drag events happening every frame the replay recorder record new frames every 25ms and end up dropping half the replay frames per flush interval, so I increased the sample interval to 50ms so the buffer size matches the sample rate exactly (50ms -> 20 samples per flush every 1000ms). I also increased the buffer size in the replay player a bit so slight fluctuations in latency won't make it start to drop frames. https://github.com/user-attachments/assets/b810cb85-db02-4edf-a63e-bfc96cf59665 https://github.com/user-attachments/assets/4d2f884d-fcce-4948-9659-fbb314634cb8 --------- Co-authored-by: Dean Herbert --- .../Visual/RankedPlay/TestSceneHandReplay.cs | 4 +- .../RankedPlay/TestScenePlayerCardHand.cs | 43 ++- .../Online/RankedPlay/RankedPlayCardState.cs | 24 ++ .../Card/RankedPlayCard.SongPreview.cs | 1 + .../Matchmaking/RankedPlay/DiscardScreen.cs | 35 +-- .../RankedPlay/Hand/HandOfCards.HandCard.cs | 136 ++++++++- .../RankedPlay/Hand/HandOfCards.cs | 279 +++++++++++++----- .../RankedPlay/Hand/HandReplayPlayer.cs | 2 +- .../RankedPlay/Hand/HandReplayRecorder.cs | 2 +- .../RankedPlay/Hand/OpponentHandOfCards.cs | 18 ++ .../Hand/PlayerHandOfCards.PlayerHandCard.cs | 31 ++ .../RankedPlay/Hand/PlayerHandOfCards.cs | 63 +++- .../RankedPlay/OpponentPickScreen.cs | 63 ++-- .../Matchmaking/RankedPlay/PickScreen.cs | 40 ++- .../RankedPlayCardWithPlaylistItem.cs | 5 + 15 files changed, 599 insertions(+), 147 deletions(-) diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs index 304b6c2238..ceccf6c340 100644 --- a/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs @@ -75,14 +75,14 @@ namespace osu.Game.Tests.Visual.RankedPlay } private double flushInterval = 1000; - private double recordInterval = 25; + private double recordInterval = 50; private double fixedLatency; private double maxLatency; [Test] public void TestCardHandReplay() { - AddSliderStep("record interval", 0.0, 1000.0, 25.0, value => + AddSliderStep("record interval", 0.0, 1000.0, 50.0, value => { recordInterval = value; recreateRecorder(); diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs index a567079e20..302721b5ce 100644 --- a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs @@ -6,6 +6,7 @@ using Humanizer; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; @@ -33,12 +34,23 @@ namespace osu.Game.Tests.Visual.RankedPlay }; } + [SetUpSteps] + public void SetupSteps() + { + AddStep("reset card hand", () => Child = handOfCards = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }); + } + [Test] public void TestSingleSelectionMode() { AddStep("add cards", () => { - handOfCards.Clear(); for (int i = 0; i < 5; i++) handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); }); @@ -59,7 +71,6 @@ namespace osu.Game.Tests.Visual.RankedPlay { AddStep("add cards", () => { - handOfCards.Clear(); for (int i = 0; i < 5; i++) handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); }); @@ -84,7 +95,13 @@ namespace osu.Game.Tests.Visual.RankedPlay AddStep($"{i} {"cards".Pluralize(i == 1)}", () => { - handOfCards.Clear(); + Child = handOfCards = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }; for (int j = 0; j < numCards; j++) handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); @@ -138,7 +155,6 @@ namespace osu.Game.Tests.Visual.RankedPlay { AddStep("add cards", () => { - handOfCards.Clear(); for (int i = 0; i < 5; i++) handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); }); @@ -157,5 +173,24 @@ namespace osu.Game.Tests.Visual.RankedPlay AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); } } + + [Test] + public void TestContract() + { + AddStep("add cards", () => + { + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddWaitStep("wait", 5); + AddStep("contract", () => handOfCards.Contract()); + AddWaitStep("wait", 5); + AddAssert( + "all cards outside bounds", () => + handOfCards + .ChildrenOfType() + .All(card => !card.ScreenSpaceDrawQuad.AABBFloat.IntersectsWith(handOfCards.ScreenSpaceDrawQuad.AABBFloat)) + ); + } } } diff --git a/osu.Game/Online/RankedPlay/RankedPlayCardState.cs b/osu.Game/Online/RankedPlay/RankedPlayCardState.cs index bd938fb9db..3ed3d36793 100644 --- a/osu.Game/Online/RankedPlay/RankedPlayCardState.cs +++ b/osu.Game/Online/RankedPlay/RankedPlayCardState.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osuTK; namespace osu.Game.Online.RankedPlay { @@ -18,5 +19,28 @@ namespace osu.Game.Online.RankedPlay [Key(2)] public required bool Selected { get; init; } + + [Key(3)] + public required bool Dragged { get; init; } + + [Key(4)] + public required int Order { get; init; } + + [Key(5)] + public float DragX { get; init; } + + [Key(6)] + public float DragY { get; init; } + + [IgnoreMember] + public Vector2 DragPosition + { + get => new Vector2(DragX, DragY); + init + { + DragX = value.X; + DragY = value.Y; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs index b1350a9571..c1b1a057c5 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs @@ -254,6 +254,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card this.FadeOut(200); } }, true); + FinishTransforms(); } protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs index 69da87aaa0..f3a7266ae8 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs @@ -81,6 +81,10 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay Anchor = Anchor.Centre, Origin = Anchor.Centre, }, + ]; + + CenterColumn.Children = + [ discardButton = new ShearedButton { Name = "Discard Button", @@ -89,11 +93,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay Width = 150, Action = onDiscardButtonClicked, Enabled = { Value = true }, - } - ]; - - CenterColumn.Children = - [ + }, playerHand = new PlayerHandOfCards { Anchor = Anchor.BottomCentre, @@ -179,23 +179,23 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay { base.OnEntering(previous); - var screenBottomCenter = new Vector2(DrawWidth / 2, DrawHeight); - int cardCount = 0; + double delay = 0; + const double stagger = 50; foreach (var card in matchInfo.PlayerCards) { + double currentDelay = delay; + playerHand.AddCard(card, c => { - c.Position = ToSpaceOfOtherDrawable(screenBottomCenter, playerHand); + c.Position = playerHand.BottomCardInsertPosition; + c.DelayMovementOnEntering(currentDelay); }); - Scheduler.AddDelayed(() => - { - SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); - }, 50 * cardCount); - cardCount++; - } - playerHand.UpdateLayout(stagger: 50); + Scheduler.AddDelayed(() => SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample), delay); + + delay += stagger; + } } private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => @@ -297,8 +297,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay { playerHand.AddCard(card, d => { - d.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth, DrawHeight * 0.5f), playerHand); - d.Rotation = -30; + // card should enter from centre-right of screen + var cardEnterPosition = ToSpaceOfOtherDrawable(new Vector2(DrawWidth, DrawHeight * 0.5f), playerHand); + d.SetupMovementForDrawnCard(cardEnterPosition); }); SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs index 43e8cbf270..7e1046c72f 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs @@ -1,11 +1,12 @@ // 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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Utils; using osu.Game.Online.RankedPlay; using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; using osuTK; @@ -16,8 +17,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand { public partial class HandCard : CompositeDrawable { - public float LayoutWidth => DrawWidth * (State.Hovered ? hover_scale : 1); - private readonly Bindable state = new Bindable(); public RankedPlayCardState State @@ -44,6 +43,28 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand set => State = State with { Pressed = value }; } + public bool CardDragged + { + get => State.Dragged; + set => State = State with { Dragged = value }; + } + + public bool CardHoveredOrDragged => CardHovered || CardDragged; + + public Vector2 DragPosition + { + get => State.DragPosition; + set => State = State with { DragPosition = value }; + } + + public int Order + { + get => State.Order; + set => State = State with { Order = value }; + } + + public CardLayout LayoutTarget { get; set; } + [Resolved] private HandOfCards handOfCards { get; set; } = null!; @@ -63,20 +84,24 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand AddInternal(Card = card); - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; } protected override void LoadComplete() { base.LoadComplete(); + positionSpring.Current = positionSpring.PreviousTarget = Position; + scaleSpring.Current = scaleSpring.PreviousTarget = 1; + rotationSpring.Current = rotationSpring.PreviousTarget = Rotation; + state.BindValueChanged(OnStateChanged, true); } protected virtual void OnStateChanged(ValueChangedEvent state) { - handOfCards.OnCardStateChanged(this, state.NewValue); + handOfCards.OnCardStateChanged(this, state); Card.ShowSelectionOutline = state.NewValue.Selected; @@ -90,6 +115,22 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand Card.ScaleTo(1f, 400, Easing.OutElasticHalf); break; } + + if (state.NewValue.Dragged) + { + // while card is being dragged card should slowly swing from side to side, + // so frequency is lowered and elasticity is increased + rotationSpring.NaturalFrequency = 2f; + rotationSpring.Damping = 0.4f; + rotationSpring.Response = 1.2f; + } + else + { + // otherwise rotation should be more snappy and not feel elastic + rotationSpring.NaturalFrequency = 3f; + rotationSpring.Damping = 0.75f; + rotationSpring.Response = 0.8f; + } } public RankedPlayCard Detach() @@ -102,11 +143,92 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand return Card; } + private bool updateMovement = true; + + private static readonly SpringParameters default_position_spring_parameters = new SpringParameters + { + NaturalFrequency = 4f, + Response = 1.1f, + Damping = 0.8f + }; + + private readonly Vector2Spring positionSpring = new Vector2Spring { Parameters = default_position_spring_parameters }; + + private readonly FloatSpring rotationSpring = new FloatSpring + { + NaturalFrequency = 2f, + Damping = 0.4f, + Response = 1.2f, + }; + + private readonly FloatSpring scaleSpring = new FloatSpring + { + NaturalFrequency = 4f, + Response = 1.3f, + Damping = 0.75f, + Current = 1, + PreviousTarget = 1, + }; + protected override void Update() { base.Update(); - Card.Elevation = float.Lerp(CardHovered ? 1 : 0, Card.Elevation, (float)Math.Exp(-0.03f * Time.Elapsed)); + if (updateMovement) + { + Position = positionSpring.Update(Time.Elapsed, LayoutTarget.Position); + Scale = new Vector2(scaleSpring.Update(Time.Elapsed, LayoutTarget.Scale)); + + float targetRotation = LayoutTarget.Rotation; + + if (CardDragged) + { + targetRotation += positionSpring.Velocity.X * 0.006f; + } + + Rotation = rotationSpring.Update(Time.Elapsed, targetRotation); + + Card.Elevation = (float)Interpolation.DampContinuously(Card.Elevation, CardHoveredOrDragged ? 1 : 0, 25, Time.Elapsed); + } + } + + /// + /// Delays the time until a card starts to move to its layout position, intended to use for staggered movement when adding multiple cards to the hand at once. + /// Movement is slowed down a bit while it's moving towards the target position to make the transition appear less abrupt. + /// + public void DelayMovementOnEntering(double delay) + { + const double approximate_time_until_position_reached = 200; + + updateMovement = false; + + this.Delay(delay) + .Schedule(() => + { + updateMovement = true; + positionSpring.NaturalFrequency = 2.5f; + }) + .Delay(approximate_time_until_position_reached) + .Schedule(() => + { + positionSpring.Parameters = default_position_spring_parameters; + }); + } + + /// + /// Makes the card move towards its layout position from a given and updates + /// movement parameters so the card moves towards it's target position more slowly and less springy. + /// + public void SetupMovementForDrawnCard(Vector2 position) + { + const double approximate_time_until_position_reached = 200; + + Position = position; + + positionSpring.NaturalFrequency = 2f; + positionSpring.Damping = 1f; + + Scheduler.AddDelayed(() => positionSpring.Parameters = default_position_spring_parameters, approximate_time_until_position_reached); } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs index 935d8fb3b8..8854562b12 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -23,15 +24,17 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand [Cached] public abstract partial class HandOfCards : CompositeDrawable { - private const float hover_scale = 1.2f; + protected const float HOVER_SCALE = 1.2f; - public IEnumerable Cards => cardContainer.Children; + private const float card_spacing = -15; + + public IReadOnlyList Cards => cardContainer.Children; /// /// How far a card slides upwards when hovered. /// Used for making sure a card moves entirely into frame when the hand is partially off-screen. /// - public float HoverYOffset = 15; + public float HoverYOffset = 35; /// /// If true, card layout will be flipped on both axes for a card hand placed at the top edge of the screen, while keeping the cards upright. @@ -39,13 +42,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand /// protected virtual bool Flipped => false; - private readonly Container cardContainer; + /// + /// Position to insert cards at so they are start moving from the bottom relative to the card layout + /// + public Vector2 BottomCardInsertPosition => new Vector2(0, (DrawHeight + RankedPlayCard.SIZE.Y) / 2 * (Flipped ? -1 : 1)); + + private readonly CardContainer cardContainer; private readonly Dictionary cardLookup = new Dictionary(); protected HandOfCards() { - AddInternal(cardContainer = new Container + AddInternal(cardContainer = new CardContainer { RelativeSizeAxes = Axes.Both, }); @@ -55,6 +63,12 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand { base.Update(); + if (!drawOrderBacking.IsValid) + { + cardContainer.Sort(); + drawOrderBacking.Validate(); + } + if (!layoutBacking.IsValid) { updateLayout(); @@ -76,17 +90,20 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand foreach (var card in cardContainer) { - card.Delay(delay) - .MoveTo(new Vector2(0, Flipped ? -220 : 220), 400, Easing.OutExpo) - .RotateTo(0, 400, Easing.OutExpo) - .ScaleTo(1, 400, Easing.OutExpo); + Scheduler.AddDelayed(() => + { + card.LayoutTarget = new CardLayout + { + Position = new Vector2(0, (DrawHeight + RankedPlayCard.SIZE.Y + 10) / 2 * (Flipped ? -1 : 1)), + Rotation = 0, + Scale = 1, + }; + }, delay); delay += 50; } } - private Anchor cardAnchor => Flipped ? Anchor.TopCentre : Anchor.BottomCentre; - public void AddCard(RankedPlayCardWithPlaylistItem item, Action? setupAction = null) => AddCard(new RankedPlayCard(item), setupAction); public void AddCard(RankedPlayCard card, Action? setupAction = null) @@ -95,12 +112,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand return; var drawable = CreateHandCard(card); - drawable.Anchor = drawable.Origin = cardAnchor; cardLookup[card.Item.Card] = drawable; + drawable.Position = GetArcPosition(0); + + if (card.Item.DisplayOrder != null) + drawable.Order = card.Item.DisplayOrder.Value; + else if (cardContainer.Count > 0) + drawable.Order = cardContainer.Max(c => c.Order) + 1; + cardContainer.Add(drawable); - layoutBacking.Invalidate(); + InvalidateLayout(drawOrder: true); setupAction?.Invoke(drawable); } @@ -113,8 +136,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand return false; cardContainer.Remove(drawable, true); - layoutBacking.Invalidate(); - return false; + InvalidateLayout(drawOrder: true); + return true; } /// @@ -137,19 +160,19 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand card = drawable.Detach(); cardContainer.Remove(drawable, true); - layoutBacking.Invalidate(); + InvalidateLayout(drawOrder: true); return true; } protected virtual HandCard CreateHandCard(RankedPlayCard card) => new HandCard(card); - protected virtual void OnCardStateChanged(HandCard card, RankedPlayCardState state) + protected virtual void OnCardStateChanged(HandCard card, ValueChangedEvent evt) { - InvalidateLayout(); + InvalidateLayout(drawOrder: affectsDrawOrder(evt)); // hovered state can be caused by keyboard focus, in which case we have to clean up after the other cards manually - if (state.Hovered) + if (evt.NewValue.Hovered) { foreach (var c in cardContainer) { @@ -159,95 +182,195 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand } } + private static bool affectsDrawOrder(ValueChangedEvent evt) => + evt.OldValue.Order != evt.NewValue.Order || + evt.OldValue.Dragged != evt.NewValue.Dragged; + #region Layout private readonly Cached layoutBacking = new Cached(); + private readonly Cached drawOrderBacking = new Cached(); - protected void InvalidateLayout() => layoutBacking.Invalidate(); - - public void UpdateLayout(double stagger = 0) + /// + /// Invalidates the layout of the hand of cards, causing a relayout to occur. + /// + /// If set to true, also invalidates the draw order of the cards. + protected void InvalidateLayout(bool drawOrder = false) { - updateLayout(stagger); - layoutBacking.Validate(); + layoutBacking.Invalidate(); + if (drawOrder) + drawOrderBacking.Invalidate(); } - private void updateLayout(double stagger = 0) + private void updateLayout() { if (Contracted) return; - const float spacing = -20; + // card container draws dragged card on top so we need to sort those separately + var cards = cardContainer.Children.OrderBy(static c => c.State.Order).ToArray(); - float totalWidth = cardContainer.Sum(it => it.LayoutWidth + spacing) - spacing; + int activeCardIndex = GetActiveCardIndex(cards); - float x = -totalWidth / 2; - - const int no_card_hovered = -1; - int hoverIndex = no_card_hovered; - - for (int i = 0; i < cardContainer.Count; i++) + for (int i = 0; i < cards.Length; i++) { - if (cardContainer[i].CardHovered) - { - hoverIndex = i; - break; - } + var card = cards[i]; + + var layout = card.CardDragged + ? CalculateDraggedCardLayout(card.DragPosition) + : CalculateCardLayout(i, activeCardIndex); + + if (Flipped) + layout.Position *= -1; + + card.LayoutTarget = layout; + } + } + + protected int GetActiveCardIndex(IReadOnlyList cards) + { + // the mouse can temporarily leave the dragged card, so dragged card should take precedence + for (int i = 0; i < cards.Count; i++) + { + if (cards[i].CardDragged) + return i; } - double delay = 0; - - for (int i = 0; i < cardContainer.Count; i++) + for (int i = 0; i < cards.Count; i++) { - var child = cardContainer[i]; + if (cards[i].CardHovered) + return i; + } - x += child.LayoutWidth / 2; + return -1; + } - float yOffset = 0; + protected CardLayout CalculateCardLayout(int index, int activeIndex) + { + float x = GetCardX(index, activeIndex); - var position = new Vector2(x, MathF.Pow(MathF.Abs(x / 250), 2) * 20 - 10); + var position = GetArcPosition(x); + float rotation = GetArcRotation(x); - if (hoverIndex != no_card_hovered && cardContainer.Children.Count > 1) - { - int distance = Math.Abs(i - hoverIndex); - int direction = Math.Sign(i - hoverIndex); + if (index == activeIndex) + position += GetCardUpwardsDirection(rotation) * HoverYOffset; - position.X += direction switch + return new CardLayout + { + Position = position, + Rotation = rotation, + Scale = index == activeIndex ? HOVER_SCALE : 1, + }; + } + + protected virtual CardLayout CalculateDraggedCardLayout(Vector2 dragPosition) + { + return new CardLayout + { + Position = dragPosition, + Rotation = 0, + Scale = HOVER_SCALE, + }; + } + + /// + /// Represents the total width of the layout for all cards in the hand. + /// + /// + /// Does not account for extra space needed for spreading the cards adjacent to the active card apart. + /// + protected float TotalLayoutWidth => cardContainer.Count * (RankedPlayCard.SIZE.X + card_spacing) - card_spacing; + + protected float GetCardX(int index, int activeIndex) + { + float x = -TotalLayoutWidth / 2 + + index * (RankedPlayCard.SIZE.X + card_spacing) + + RankedPlayCard.SIZE.X / 2; + + if (activeIndex < 0 || cardContainer.Count <= 1) + return x; + + // if a card is hovered or dragged, the adjacent cards should get spread apart + int distance = Math.Abs(index - activeIndex); + int direction = Math.Sign(index - activeIndex); + + float baseOffset = RankedPlayCard.SIZE.X * 0.1f; + + switch (direction) + { + case -1: + if (cardContainer.Count == 2) { - 0 => 0, - // special case for the left card when there's only 2 cards // too much offset looks kinda odd here so it's reduced - < 0 when cardContainer.Count == 2 => -3, + x -= baseOffset + 3; + break; + } - < 0 => -10 / MathF.Pow(distance, 3), + x -= baseOffset + 10 / MathF.Pow(distance, 2); + break; - // cards right to the hovered card have a higher offset because they are partially - // covering the cards to their left - > 0 => 20 / MathF.Pow(distance, 2), - }; + case 1: + // cards right to the active card have a higher offset because they are partially + // covering the cards to their left + x += baseOffset + 20 / MathF.Pow(distance, 2); + break; + } + + return x; + } + + /// + /// Calculates the position of a card at a given coordinate so all cards are laid out in an arc + /// + protected Vector2 GetArcPosition(float x) + { + float offset = (DrawHeight - RankedPlayCard.SIZE.Y) / 2; + + return new Vector2(x, MathF.Pow(MathF.Abs(x / 250), 2) * 20 + offset); + } + + /// + /// Calculates the rotation of a card at a given coordinate + /// + protected static float GetArcRotation(float x) => x * 0.03f; + + protected static Vector2 GetCardUpwardsDirection(float rotation) + { + float angle = MathHelper.DegreesToRadians(rotation - 90); + + return new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + } + + private partial class CardContainer : Container + { + protected override int Compare(Drawable x, Drawable y) + { + if (x is HandCard c1 && y is HandCard c2) + { + // dragged cards should always be drawn on top + if (c1.CardDragged) + return 1; + + if (c2.CardDragged) + return -1; + + int result = c1.Order.CompareTo(c2.Order); + if (result != 0) + return result; } - if (child.CardHovered) - yOffset = -HoverYOffset; - - float rotation = x * 0.03f; - - float angle = MathHelper.DegreesToRadians(rotation + 90); - - position += new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * yOffset; - - position *= Flipped ? -1 : 1; - - child - .Delay(delay) - .MoveTo(position, 300, Easing.OutExpo) - .RotateTo(rotation, 300, Easing.OutExpo) - .ScaleTo(child.CardHovered ? hover_scale : 1f, 400, Easing.OutElasticQuarter); - - x += child.LayoutWidth / 2 + spacing; - - delay += stagger; + return base.Compare(x, y); } + + public void Sort() => SortInternal(); + } + + public struct CardLayout + { + public required Vector2 Position { get; set; } + public required float Rotation { get; set; } + public required float Scale { get; set; } } #endregion diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs index e7c6fe0ec8..3e29b335cd 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand /// /// Maximum amount of frames that can get queued up at the same time /// - public int MaxQueuedFrames { get; set; } = 20; + public int MaxQueuedFrames { get; set; } = 30; private readonly int userId; private readonly OpponentHandOfCards handOfCards; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs index 9c4bf1d1cb..d0e18e3b49 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs @@ -20,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand /// /// Minimum interval between individual replay frames /// - public double RecordInterval { get; init; } = 25; + public double RecordInterval { get; init; } = 50; /// /// Max amount of frames to collect per diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs index e2e27d820d..c73680deb4 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Game.Online.RankedPlay; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand { @@ -24,5 +25,22 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand card.State = cardState; } } + + protected override CardLayout CalculateDraggedCardLayout(Vector2 dragPosition) + { + // the opponent shouldn't be able to drag his card across the entire screen. + // card movement is limited to roughly the width of the hand horizontally + // and has a fixed vertical offset (extended slightly further than when hovered) + float maxExtent = TotalLayoutWidth / 2; + + float x = float.Clamp(dragPosition.X, -maxExtent, maxExtent); + + return new CardLayout + { + Position = GetArcPosition(x) + new Vector2(0, -60), + Rotation = 0, + Scale = HOVER_SCALE, + }; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs index c4021d7df9..07248af522 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs @@ -33,6 +33,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand public required Action Clicked; + public required Action Dragged; + public required IBindable AllowSelection; private readonly Drawable cardInputArea; @@ -165,6 +167,35 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand CardHovered = false; } + + #region Drag/Drop + + private Vector2 dragOffset; + + protected override bool OnDragStart(DragStartEvent e) + { + dragOffset = DrawPosition + AnchorPosition - e.MouseDownPosition; + + CardDragged = true; + + return true; + } + + protected override void OnDrag(DragEvent e) + { + DragPosition = e.MousePosition - AnchorPosition + dragOffset; + + Dragged(this, e.ScreenSpaceMousePosition); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + + CardDragged = false; + } + + #endregion } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs index 7ec73b7662..dbcd2d507d 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -12,6 +13,7 @@ using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Online.RankedPlay; using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; using osuTK.Input; namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand @@ -103,6 +105,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand protected override HandCard CreateHandCard(RankedPlayCard card) => new PlayerHandCard(card) { Clicked = cardClicked, + Dragged = cardDragged, AllowSelection = allowSelection.GetBoundCopy(), PlayAction = PlayCardAction, }; @@ -138,18 +141,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand } } - protected override void OnCardStateChanged(HandCard card, RankedPlayCardState state) + protected override void OnCardStateChanged(HandCard card, ValueChangedEvent evt) { StateChanged?.Invoke(); - base.OnCardStateChanged(card, state); + base.OnCardStateChanged(card, evt); } public Dictionary State => Cards.Select(static card => new KeyValuePair(card.Item.Card.ID, card.State)).ToDictionary(); protected override bool OnKeyDown(KeyDownEvent e) { - if (e.Repeat || Contracted) + if (e.Repeat || Contracted || Cards.Any(static c => c.CardDragged)) return false; switch (e.Key) @@ -196,8 +199,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand int newIndex = currentIndex + direction; if (newIndex < 0) - newIndex = Cards.Count() - 1; - else if (newIndex >= Cards.Count()) + newIndex = Cards.Count - 1; + else if (newIndex >= Cards.Count) newIndex = 0; focusCard(newIndex); @@ -215,5 +218,55 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand if (SelectionMode == HandSelectionMode.Single && !card.Selected) card.TriggerClick(); } + + private void cardDragged(PlayerHandCard card, Vector2 screenSpacePosition) + { + var cards = Cards.OrderBy(static c => c.Order).ToArray(); + + int newIndex = cardIndexInLayout(cards, card.ScreenSpaceDrawQuad.Centre); + + card.Order = newIndex; + + int order = 0; + + foreach (var c in cards) + { + if (order == newIndex) + order++; + + if (c == card) + continue; + + c.Order = order++; + } + + foreach (var c in Cards) + c.Item.DisplayOrder = c.Order; + } + + private int cardIndexInLayout(HandCard[] cards, Vector2 screenSpacePosition) + { + Debug.Assert(cards.Length > 0); + + var position = ToLocalSpace(screenSpacePosition) - DrawSize / 2; + + int activeIndex = GetActiveCardIndex(cards); + + int minIndex = 0; + float minDistance = float.MaxValue; + + for (int i = 0; i < cards.Length; i++) + { + float distance = MathF.Abs(GetCardX(i, activeIndex) - position.X); + + if (distance < minDistance) + { + minDistance = distance; + minIndex = i; + } + } + + return minIndex; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs index aa8f392890..266451e52a 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs @@ -34,6 +34,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay [Resolved] private RankedPlayMatchInfo matchInfo { get; set; } = null!; + private Sample? cardAddSample; + private const int card_play_samples = 2; private Sample?[]? cardPlaySamples; @@ -56,15 +58,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay CenterColumn.Children = [ - playerHand = new PlayerHandOfCards - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Y = 100, - HoverYOffset = 90 - }, opponentHand = new OpponentHandOfCards { Anchor = Anchor.TopCentre, @@ -72,10 +65,19 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay RelativeSizeAxes = Axes.Both, Height = 0.5f, }, + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }, new HandReplayRecorder(playerHand), new HandReplayPlayer(matchInfo.OpponentId, opponentHand), ]; + cardAddSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-add-1"); + cardPlaySamples = new Sample?[card_play_samples]; for (int i = 0; i < card_play_samples; i++) cardPlaySamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Ranked/card-play-{1 + i}"); @@ -85,24 +87,51 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay { base.OnEntering(previous); - foreach (var card in matchInfo.PlayerCards) + const double stagger = 50; + double delay = 0; + + foreach (var item in matchInfo.PlayerCards) { - playerHand.AddCard(card, c => + double currentDelay = delay; + + if ((previous as DiscardScreen)?.CenterRow.RemoveCard(item, out var card, out var drawQuad) == true) { - c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); - }); + playerHand.AddCard(card, c => + { + c.MatchScreenSpaceDrawQuad(drawQuad, playerHand); + c.DelayMovementOnEntering(currentDelay); + }); + } + else + { + playerHand.AddCard(item, c => + { + c.Position = playerHand.BottomCardInsertPosition; + c.DelayMovementOnEntering(currentDelay); + }); + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, delay); + } + + delay += stagger; } + delay = 0; + foreach (var card in matchInfo.OpponentCards) { + double currentDelay = delay; + opponentHand.AddCard(card, c => { - c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + c.Position = opponentHand.BottomCardInsertPosition; + c.DelayMovementOnEntering(currentDelay); }); - } - playerHand.UpdateLayout(stagger: 50); - opponentHand.UpdateLayout(stagger: 50); + delay += 50; + } } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs index 2d94e28ff3..43076292e2 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs @@ -73,6 +73,14 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay CenterColumn.Children = [ + opponentHand = new OpponentHandOfCards + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Y = -100, + }, playerHand = new PlayerHandOfCards { Anchor = Anchor.BottomCentre, @@ -82,14 +90,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay SelectionMode = HandSelectionMode.Single, PlayCardAction = onPlayButtonClicked }, - opponentHand = new OpponentHandOfCards - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Y = -100, - }, new HandReplayRecorder(playerHand), new HandReplayPlayer(matchInfo.OpponentId, opponentHand), ]; @@ -149,41 +149,51 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay { base.OnEntering(previous); - int delay = 0; + const double stagger = 50; + double delay = 0; foreach (var item in matchInfo.PlayerCards) { + double currentDelay = delay; + if ((previous as DiscardScreen)?.CenterRow.RemoveCard(item, out var card, out var drawQuad) == true) { playerHand.AddCard(card, c => { c.MatchScreenSpaceDrawQuad(drawQuad, playerHand); + c.DelayMovementOnEntering(currentDelay); }); } else { playerHand.AddCard(item, c => { - c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); + c.Position = playerHand.BottomCardInsertPosition; + c.DelayMovementOnEntering(currentDelay); }); Scheduler.AddDelayed(() => { SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); - }, 50 * delay); - delay++; + }, delay); } + + delay += stagger; } + delay = 0; + foreach (var item in matchInfo.OpponentCards) { + double currentDelay = delay; + opponentHand.AddCard(item, c => { c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + c.DelayMovementOnEntering(currentDelay); }); - } - playerHand.UpdateLayout(stagger: 50); - opponentHand.UpdateLayout(stagger: 50); + delay += 50; + } } private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs index 71ef9ee9db..2554dd1e1c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs @@ -13,6 +13,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay public readonly Bindable PlaylistItem = new Bindable(); public readonly RankedPlayCardItem Card; + /// + /// The player's preferred display order for this card + /// + public int? DisplayOrder { get; set; } + public RankedPlayCardWithPlaylistItem(RankedPlayCardItem card) { Card = card;