1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-13 20:33:35 +08:00

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 (e0a46fefaf), 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 <pe@ppy.sh>
This commit is contained in:
maarvin
2026-04-01 11:21:44 +02:00
committed by GitHub
Unverified
parent 5c20254f76
commit c4a49f647f
15 changed files with 599 additions and 147 deletions
@@ -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();
@@ -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<HandOfCards.HandCard>()
.All(card => !card.ScreenSpaceDrawQuad.AABBFloat.IntersectsWith(handOfCards.ScreenSpaceDrawQuad.AABBFloat))
);
}
}
}
@@ -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;
}
}
}
}
@@ -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)
@@ -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);
@@ -1,11 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<RankedPlayCardState> state = new Bindable<RankedPlayCardState>();
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<RankedPlayCardState> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
});
}
/// <summary>
/// Makes the card move towards its layout position from a given <paramref name="position"/> and updates
/// movement parameters so the card moves towards it's target position more slowly and less springy.
/// </summary>
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);
}
}
}
@@ -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<HandCard> Cards => cardContainer.Children;
private const float card_spacing = -15;
public IReadOnlyList<HandCard> Cards => cardContainer.Children;
/// <summary>
/// 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.
/// </summary>
public float HoverYOffset = 15;
public float HoverYOffset = 35;
/// <summary>
/// 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
/// </summary>
protected virtual bool Flipped => false;
private readonly Container<HandCard> cardContainer;
/// <summary>
/// Position to insert cards at so they are start moving from the bottom relative to the card layout
/// </summary>
public Vector2 BottomCardInsertPosition => new Vector2(0, (DrawHeight + RankedPlayCard.SIZE.Y) / 2 * (Flipped ? -1 : 1));
private readonly CardContainer cardContainer;
private readonly Dictionary<RankedPlayCardItem, HandCard> cardLookup = new Dictionary<RankedPlayCardItem, HandCard>();
protected HandOfCards()
{
AddInternal(cardContainer = new Container<HandCard>
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<HandCard>? setupAction = null) => AddCard(new RankedPlayCard(item), setupAction);
public void AddCard(RankedPlayCard card, Action<HandCard>? 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;
}
/// <summary>
@@ -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<RankedPlayCardState> 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<RankedPlayCardState> 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)
/// <summary>
/// Invalidates the layout of the hand of cards, causing a relayout to occur.
/// </summary>
/// <param name="drawOrder">If set to true, also invalidates the draw order of the cards.</param>
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<HandCard> 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,
};
}
/// <summary>
/// Represents the total width of the layout for all cards in the hand.
/// </summary>
/// <remarks>
/// Does not account for extra space needed for spreading the cards adjacent to the active card apart.
/// </remarks>
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;
}
/// <summary>
/// Calculates the position of a card at a given <paramref name="x" /> coordinate so all cards are laid out in an arc
/// </summary>
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);
}
/// <summary>
/// Calculates the rotation of a card at a given <paramref name="x"/> coordinate
/// </summary>
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<HandCard>
{
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
@@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand
/// <summary>
/// Maximum amount of frames that can get queued up at the same time
/// </summary>
public int MaxQueuedFrames { get; set; } = 20;
public int MaxQueuedFrames { get; set; } = 30;
private readonly int userId;
private readonly OpponentHandOfCards handOfCards;
@@ -20,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand
/// <summary>
/// Minimum interval between individual replay frames
/// </summary>
public double RecordInterval { get; init; } = 25;
public double RecordInterval { get; init; } = 50;
/// <summary>
/// Max amount of frames to collect per <see cref="FlushInterval"/>
@@ -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,
};
}
}
}
@@ -33,6 +33,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand
public required Action<PlayerHandCard> Clicked;
public required Action<PlayerHandCard, Vector2> Dragged;
public required IBindable<bool> 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
}
}
}
@@ -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<RankedPlayCardState> evt)
{
StateChanged?.Invoke();
base.OnCardStateChanged(card, state);
base.OnCardStateChanged(card, evt);
}
public Dictionary<Guid, RankedPlayCardState> State => Cards.Select(static card => new KeyValuePair<Guid, RankedPlayCardState>(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;
}
}
}
@@ -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()
@@ -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(() =>
@@ -13,6 +13,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
public readonly Bindable<MultiplayerPlaylistItem?> PlaylistItem = new Bindable<MultiplayerPlaylistItem?>();
public readonly RankedPlayCardItem Card;
/// <summary>
/// The player's preferred display order for this card
/// </summary>
public int? DisplayOrder { get; set; }
public RankedPlayCardWithPlaylistItem(RankedPlayCardItem card)
{
Card = card;