Resolves#37486
Original error log:
```
2026-04-22 21:47:58 [error]: System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
2026-04-22 21:47:58 [error]: at System.Collections.Generic.List`1.get_Item(Int32 index)
2026-04-22 21:47:58 [error]: at osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand.PlayerHandOfCards.moveCardFocus(Int32 direction)
2026-04-22 21:47:58 [error]: at osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand.PlayerHandOfCards.OnKeyDown(KeyDownEvent e)
```
Currently,
```osu.Game\Screens\OnlinePlay\Matchmaking\RankedPlay\Hand\PlayerHandOfCards.cs::moveCardFocus```
does not account for the hand being empty (cards.Count ==0 ), and will
attempt to move the card focus in the given direction regardless, which
causes the above index out of range error.
Added empty hand check to moveCardFocus, and a matching test case to
TestScenePlayerCardHand.cs
New test case before changes, recreating the error:
<img width="986" height="232" alt="image"
src="https://github.com/user-attachments/assets/daa62081-c776-44bd-b0d2-382b2dac7938"
/>
After:
<img width="638" height="223" alt="image"
src="https://github.com/user-attachments/assets/d6dcd8b8-8caf-42e3-9999-93dfe3fb6452"
/>
Attempt at adressing the points made in
https://github.com/ppy/osu/pull/37419#issuecomment-4279123323
- `CardContainer` is now being sorted immediately instead of only doing
it once per frame. Given that its only ever gonna have a handful of
children there wasn't really a need to optimize that that in the first
place.
- `HandOfCards.Cards` now exposes `cardLookup.Values` as an
`IEnumerable` instead of exposing the card container's children
directly.
- `HandOfCard` now exposes `GetCardsInDisplayOrder` which returns a copy
of all cards in display order. Since it's making a copy I made sure this
isn't called on any hot code paths.
- `HandOfCard.Clear` previously didn't clear the `cardLookup`
dictionary. Didn't cause any issues since we're not re-adding cards to
the hand anywhere but not good regardless.
Switched to looping over all cards and calling `RemoveCard` to make sure
changes to the removal logic can't get overlooked there again.
---------
Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
Closes: #37402
Issue was `HandOfCards.CardContainer.Compare` working under the
assumption that the it would never be called with the same child for
both entries, which can happen when doing a `BinarySearch` (called in
`RemoveInternal`).
This lead to `IndexOf` returning a negative value despite the card being
present in the container, and the drawable getting disposed but not
actually removed.
I also included a precautionary `cardContainer.Sort()` call before the
removal as well, seemed to work without that from testing but better not
rely on that.
TLDR: card container child sorting was unstable due to poor assumptions
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-bfc96cf59665https://github.com/user-attachments/assets/4d2f884d-fcce-4948-9659-fbb314634cb8
---------
Co-authored-by: Dean Herbert <pe@ppy.sh>