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;