Skip to content

Ranked Play: Make cards draggable and reorderable#37157

Merged
peppy merged 27 commits intoppy:masterfrom
minetoblend:rp-draggable-cards
Apr 1, 2026
Merged

Ranked Play: Make cards draggable and reorderable#37157
peppy merged 27 commits intoppy:masterfrom
minetoblend:rp-draggable-cards

Conversation

@minetoblend
Copy link
Copy Markdown
Contributor

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 (e0a46fe), 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.
2026-03-31.01-19-27.mp4
2026-03-31.01-17-28.mp4

@peppy peppy self-requested a review March 31, 2026 19:33

Card.Elevation = float.Lerp(CardHovered ? 1 : 0, Card.Elevation, (float)Math.Exp(-0.03f * Time.Elapsed));
if (MovementSpeed > 0)
Position = positionSpring.Update(Time.Elapsed * MovementSpeed, LayoutTarget.Position);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, this API structure matches nothing anywhere in our framework and i don't like it.

An update method returning a value? An interpolation not housed within the Interpolation class?

A value being provided to elapsed which is multiplied by some variable and no longer conveying elapsed time?

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa i dunnnno

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Spring Update method API
    It's an update method because the spring is stateful (required because it needs to keep track of it's velocity).
    Without tracking it's velocity you essentially just arrive at Interpolation.DampContinuously and lose all of the "springiness".

  • Time.Elapsed being multiplied
    Multipliplying Time.Elapsed like that is basically equivalent to applying the same operation on spring.NaturalFrequency so I replaced it with that in 32fe4f3.

  • Elevation interpolation formula
    Turns out the interpolation for the card elevation was mathematically equivalent to Interpolation.DampContinuously so I replaced it with that now (cc02c6c).
    The previous formula is equivalent to a halftime of ln(2) / 0.03 so I rounded that to 25ms.
    fwiw I originally picked up that formula from SSV1's beatmapset panel, and it's still being used in ScrollContainer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an update method because the spring is stateful (required because it needs to keep track of it's velocity).

This is fine, but having a return value in a method called Update goes against all my expectations.

new HandReplayPlayer(matchInfo.OpponentId, opponentHand),
];

cardAddSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-add-1");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this come from somewhere else?

Copy link
Copy Markdown
Contributor Author

@minetoblend minetoblend Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from the PickScreen. Noticed this was missing when going through both screens and making sure the screen-enter implementation for matches on both classes. Should have pointed this out, mb

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of copy waste in this system, seeing the same thing changes in 4 places is fun. And finding things missed like this is double the fun.

Comment on lines +95 to +97
positionSpring.Current = positionSpring.PreviousTarget = Position;
scaleSpring.Current = scaleSpring.PreviousTarget = 1;
rotationSpring.Current = rotationSpring.PreviousTarget = Rotation;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels super weird. i think this spring class needs more thought.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous target value is required so springs can "react" to input changes (controlled via the Response parameter).
The card doesn't know it's starting position at construction time yet so the spring needs to be "reset" when that happens.
Perhaps adding a spring.Reset(value) method would be appropriate for this?
Alternatively just delaying creation of the springs until constructor time would be a possibility.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'd have preferred a Reset method (PreviousTarget being exposed for writing feels very wrong).

Delaying creation is also fine.

Comment on lines +120 to +121
? new SpringParameters(2f, 0.4f, 1.2f)
: new SpringParameters(3f, 0.75f, 0.8f);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this mean? inline comment would be good

Copy link
Copy Markdown
Contributor Author

@minetoblend minetoblend Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjusted in 35be2a2.
Expanded the whole thing so names for all parameters being assigned are visible, and also added comments.
But TLDR:

  • when card dragged -> rotation should be slow but springy to simulate sway
  • when not dragged -> rotation should be snappy and have more damping, so it doesn't wobble around

To visualize the difference, this is what it would look like if the card always had the same parameters as during drag:

2026-04-01.00-25-39.mp4

Comment on lines +29 to +41
protected override CardLayout CalculateDraggedCardLayout(Vector2 dragPosition)
{
float maxExtent = TotalLayoutWidth / 2;

float x = float.Clamp(dragPosition.X, -maxExtent, maxExtent);
float arcRotation = GetArcRotation(x);

return new CardLayout
{
Position = GetArcPosition(x) + GetCardUpwardsDirection(arcRotation) * 60,
Rotation = 0,
Scale = HOVER_SCALE,
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of this really makes sense to my weary eyes.

Needs much more commenting so I can discern why this is virtual-override and what anything means.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comment in 35be2a2.
The purpose is so the opponent player cannot just drag his cards all over the screen.

Also replaced GetCardUpwardsDirection with just new Vector2(0, -60) since it just changes the x value by maybe 3 pixels while making the whole thing harder to understand. The original purpose of that was so the y offset is calculated the same way as in non-drag mode, where it actually matters since the card is rotated.

TLDR: it's to prevent this:

2026-04-01.00-30-30.mp4

and instead do this:

2026-04-01.00-31-54.mp4

@peppy
Copy link
Copy Markdown
Member

peppy commented Apr 1, 2026

I'm blind merging this.

At this poitn the code is so foreign and weird that this isn't going to make it worse. Let's just see if we can get something together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants