From 098855256751dfb1b62d995c680e746fb0675c32 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sat, 7 Mar 2026 02:30:50 +0900 Subject: [PATCH] Implement ranked play (#36819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I don't really have much to say here. Instead, I'll give a brief history rundown that lists many pages of documentation you can read, if interested. - Started off as BTMC + Happy24 (Vivi)'s ["The Vision"](https://docs.google.com/document/d/1p1IpPmd2RICp8G4OqkCSs7u8Ug8FbFv8qqP0mfSrHf0/edit?tab=t.0#heading=h.fol093d9f9xi) - Initial [designs](https://www.figma.com/design/f5qqC57t9jFlgpzhRqUNVX/The-Vision?node-id=0-1&p=f) were led by Vivi. - Designs [morphed](https://www.figma.com/design/vtFmLrXKvWNyYiRjTezFTM/Untitled--Copy-?node-id=0-1&p=f) during development into what's currently present, led by @minetoblend. - There is some more ongoing work creating a [game design document](https://docs.google.com/document/d/1iffJFCsIBfYF0D4ogItSBEj6YBmbp-rdCpItAeaJiTA/edit?tab=t.0). **tl;dr:** Create something with the mechanics of a trading card game within osu!. The name of this is "ranked play". --- To be frank, a lot of stuff is missing here. Some of it I don't want to mention, because the point of this exercise is to get the system into the hands of players, gather feedback especially around mechanics, and discuss any further direction with the team. I am expecting a blanket approval on all of the new code, with particular attention to changes in existing components that I'll point out in a self review. There is also some [ongoing work](https://github.com/smoogipoo/osu/pulls) that may arrive in this branch prior to being merged. --------- Co-authored-by: maarvin Co-authored-by: Marvin Co-authored-by: Jamie Taylor Co-authored-by: ArijanJ Co-authored-by: Dean Herbert Co-authored-by: Tim Oliver Co-authored-by: Joseph Madamba Co-authored-by: Bartłomiej Dach Co-authored-by: nil <25884226+voidstar0@users.noreply.github.com> Co-authored-by: Ботников Максим Co-authored-by: Denis Titovets Co-authored-by: Michael Middlezong <119022671+mmiddlezong@users.noreply.github.com> Co-authored-by: SupDos <6813986+SupDos@users.noreply.github.com> Co-authored-by: failaip12 <86018517+failaip12@users.noreply.github.com> --- .../Requests/api-beatmaps-rankedplay.json | 1590 +++++++++++++++++ .../TestSceneMatchmakingQueueScreen.cs | 3 +- .../Online/TestSceneBeatmapSetOverlay.cs | 4 +- .../Visual/RankedPlay/RankedPlayTestScene.cs | 78 + .../RankedPlay/TestSceneDiscardScreen.cs | 32 + .../Visual/RankedPlay/TestSceneEndedScreen.cs | 63 + .../TestSceneGameplayWarmupScreen.cs | 49 + .../Visual/RankedPlay/TestSceneHandReplay.cs | 140 ++ .../Visual/RankedPlay/TestSceneIntroScreen.cs | 37 + .../RankedPlay/TestSceneOpponentPickScreen.cs | 32 + .../Visual/RankedPlay/TestScenePickScreen.cs | 32 + .../RankedPlay/TestScenePlayerCardHand.cs | 161 ++ .../TestSceneRankedPlayBackground.cs | 62 + .../RankedPlay/TestSceneRankedPlayCard.cs | 222 +++ .../TestSceneRankedPlayCornerPiece.cs | 61 + .../RankedPlay/TestSceneRankedPlayScreen.cs | 176 ++ .../TestSceneRankedPlayUserDisplay.cs | 58 + .../RankedPlay/TestSceneResultsScreen.cs | 231 +++ .../Visual/RankedPlay/TestSceneSongPreview.cs | 80 + osu.Game/Audio/PreviewTrack.cs | 21 + osu.Game/Audio/SamplePlaybackHelper.cs | 38 + osu.Game/Localisation/ButtonSystemStrings.cs | 5 + osu.Game/Online/Leaderboards/DrawableRank.cs | 31 + .../Matchmaking/MatchmakingStage.cs | 2 +- .../Online/Multiplayer/MultiplayerClient.cs | 71 +- .../Multiplayer/OnlineMultiplayerClient.cs | 27 + osu.Game/Screens/Menu/ButtonSystem.cs | 24 +- osu.Game/Screens/Menu/MainMenu.cs | 8 +- .../Matchmaking/Intro/ScreenIntro.cs | 27 +- .../Matchmaking/Queue/PoolSelector.cs | 2 +- .../Matchmaking/Queue/QueueController.cs | 46 +- .../Matchmaking/Queue/ScreenQueue.cs | 37 +- .../CardDetailsOverlayContainer.UserTags.cs | 123 ++ .../Card/CardDetailsOverlayContainer.cs | 146 ++ .../Card/RankedPlayCard.SongPreview.cs | 306 ++++ .../RankedPlay/Card/RankedPlayCard.cs | 241 +++ .../RankedPlay/Card/RankedPlayCardBackSide.cs | 32 + .../RankedPlayCardContent.AttributeListing.cs | 173 ++ .../Card/RankedPlayCardContent.Colours.cs | 77 + .../Card/RankedPlayCardContent.Cover.cs | 55 + .../Card/RankedPlayCardContent.Metadata.cs | 203 +++ .../RankedPlay/Card/RankedPlayCardContent.cs | 147 ++ .../Card/RankedPlayCardExtensions.cs | 43 + .../Card/SongPreviewParticleContainer.cs | 129 ++ .../RankedPlay/Components/CardFlow.cs | 79 + .../Components/RankedPlayCornerPiece.cs | 160 ++ .../Components/RankedPlayScoreCounter.cs | 234 +++ .../Components/RankedPlayStageDisplay.cs | 240 +++ .../Components/RankedPlayUserDisplay.cs | 398 +++++ .../Matchmaking/RankedPlay/DiscardScreen.cs | 352 ++++ .../Matchmaking/RankedPlay/EndedScreen.cs | 207 +++ .../Matchmaking/RankedPlay/GameplayScreen.cs | 42 + .../GameplayWarmupScreen.DifficultyDisplay.cs | 234 +++ .../GameplayWarmupScreen.MetadataWedge.cs | 260 +++ .../GameplayWarmupScreen.TitleWedge.cs | 167 ++ .../RankedPlay/GameplayWarmupScreen.cs | 182 ++ .../Matchmaking/RankedPlay/HamburgerMenu.cs | 116 ++ .../RankedPlay/Hand/HandOfCards.HandCard.cs | 113 ++ .../RankedPlay/Hand/HandOfCards.cs | 255 +++ .../RankedPlay/Hand/HandReplayPlayer.cs | 72 + .../RankedPlay/Hand/HandReplayRecorder.cs | 124 ++ .../RankedPlay/Hand/HandSelectionMode.cs | 12 + .../RankedPlay/Hand/OpponentHandOfCards.cs | 28 + .../Hand/PlayerHandOfCards.PlayerHandCard.cs | 170 ++ .../RankedPlay/Hand/PlayerHandOfCards.cs | 219 +++ .../RankedPlay/Intro/CoverReveal.cs | 119 ++ .../RankedPlay/Intro/IntroScreen.cs | 123 ++ .../RankedPlay/Intro/StarRatingSequence.cs | 341 ++++ .../RankedPlay/Intro/UserWithRating.cs | 9 + .../RankedPlay/Intro/VsSequence.cs | 325 ++++ .../RankedPlay/OpponentPickScreen.cs | 160 ++ .../Matchmaking/RankedPlay/PickScreen.cs | 267 +++ .../RankedPlay/RankedPlayBackground.cs | 213 +++ .../RankedPlay/RankedPlayBackgroundScreen.cs | 110 ++ .../RankedPlayCardWithPlaylistItem.cs | 30 + .../RankedPlay/RankedPlayColourScheme.cs | 35 + .../RankedPlay/RankedPlayMatchInfo.cs | 176 ++ .../RankedPlay/RankedPlayScreen.cs | 535 ++++++ .../RankedPlay/RankedPlaySubScreen.cs | 89 + .../RankedPlay/ResultsScreen.PanelScaffold.cs | 118 ++ .../RankedPlay/ResultsScreen.ScoreBar.cs | 67 + .../RankedPlay/ResultsScreen.ScoreDetails.cs | 109 ++ .../ResultsScreen.ScoreRankDisplay.cs | 39 + .../ResultsScreen.ScoreStatisticsDisplay.cs | 70 + .../Matchmaking/RankedPlay/ResultsScreen.cs | 606 +++++++ .../BeatmapMetadataWedge.FailRetryDisplay.cs | 2 +- .../BeatmapMetadataWedge.MetadataDisplay.cs | 2 +- ...eatmapMetadataWedge.RatingSpreadDisplay.cs | 2 +- ...BeatmapMetadataWedge.SuccessRateDisplay.cs | 2 +- .../BeatmapMetadataWedge.UserRatingDisplay.cs | 2 +- osu.Game/Tests/Beatmaps/TestBeatmap.cs | 2 + .../Multiplayer/TestMultiplayerClient.cs | 156 ++ osu.Game/Tests/Visual/OsuTestScene.cs | 56 +- osu.Game/Users/UserActivity.cs | 23 +- 94 files changed, 12526 insertions(+), 51 deletions(-) create mode 100644 osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json create mode 100644 osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs create mode 100644 osu.Game/Audio/SamplePlaybackHelper.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs diff --git a/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json b/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json new file mode 100644 index 0000000000..416aff1ed6 --- /dev/null +++ b/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json @@ -0,0 +1,1590 @@ +[ + { + "beatmapset_id": 989460, + "difficulty_rating": 8.77437, + "id": 2069833, + "mode": "osu", + "status": "ranked", + "total_length": 306, + "user_id": 11, + "version": "Endless Days", + "accuracy": 9.4, + "ar": 9.8, + "bpm": 260, + "convert": false, + "count_circles": 2075, + "count_sliders": 452, + "count_spinners": 3, + "cs": 4.2, + "deleted_at": null, + "drain": 5, + "hit_length": 306, + "is_scoreable": true, + "last_updated": "2019-10-04T20:59:44Z", + "mode_int": 0, + "passcount": 5346, + "playcount": 77382, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/2069833", + "checksum": "d6b18fbcba356cfe9c6edcb21e78dfec", + "beatmapset": { + "anime_cover": false, + "artist": "Rivers of Nihil", + "artist_unicode": "Rivers of Nihil", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/cover.jpg?1631512608", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/cover@2x.jpg?1631512608", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/card.jpg?1631512608", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/card@2x.jpg?1631512608", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/list.jpg?1631512608", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/list@2x.jpg?1631512608", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/slimcover.jpg?1631512608", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/slimcover@2x.jpg?1631512608" + }, + "creator": "vrnl", + "favourite_count": 142, + "genre_id": 11, + "hype": null, + "id": 989460, + "language_id": 2, + "nsfw": false, + "offset": 0, + "play_count": 77382, + "preview_url": "\/\/b.ppy.sh\/preview\/989460.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Hollow", + "title_unicode": "Hollow", + "track_id": 1762, + "user_id": 11, + "video": false, + "bpm": 260, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2019-10-04T20:59:43Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": null, + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2019-10-15T22:45:04Z", + "rating": 9.40909, + "storyboard": false, + "submitted_date": "2019-06-18T16:00:11Z", + "tags": "where owls know my name english progressive technical death metal featured artist", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "fail": [ + 0, + 4, + 41, + 64, + 149, + 210, + 547, + 5901, + 2455, + 595, + 1318, + 9767, + 993, + 687, + 4161, + 673, + 48, + 5, + 14, + 32, + 9, + 2, + 5, + 14, + 70, + 37, + 56, + 126, + 60, + 43, + 55, + 59, + 72, + 161, + 310, + 71, + 112, + 22, + 286, + 1038, + 5569, + 3662, + 16, + 15, + 85, + 72, + 12, + 15, + 18, + 2, + 47, + 114, + 34, + 96, + 82, + 34, + 56, + 46, + 81, + 98, + 66, + 12, + 172, + 52, + 3, + 0, + 3, + 0, + 10, + 0, + 0, + 28, + 2, + 0, + 0, + 0, + 0, + 2, + 5, + 118, + 745, + 415, + 102, + 2, + 1, + 29, + 3, + 1, + 0, + 0, + 109, + 79, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "exit": [ + 0, + 0, + 1976, + 1345, + 1068, + 877, + 261, + 1636, + 1133, + 713, + 1050, + 2397, + 1079, + 1191, + 1394, + 779, + 269, + 257, + 252, + 297, + 221, + 94, + 69, + 133, + 223, + 227, + 212, + 199, + 141, + 168, + 110, + 102, + 61, + 112, + 162, + 93, + 110, + 89, + 103, + 160, + 625, + 694, + 162, + 228, + 433, + 347, + 263, + 181, + 172, + 79, + 229, + 311, + 173, + 231, + 268, + 207, + 176, + 87, + 95, + 168, + 64, + 46, + 55, + 71, + 50, + 33, + 40, + 10, + 14, + 8, + 5, + 41, + 16, + 2, + 15, + 7, + 18, + 16, + 10, + 71, + 99, + 70, + 104, + 57, + 44, + 104, + 60, + 19, + 22, + 48, + 114, + 86, + 66, + 58, + 30, + 55, + 28, + 16, + 19, + 42 + ] + }, + "max_combo": 3275, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1681275, + "difficulty_rating": 3.72672, + "id": 3631491, + "mode": "osu", + "status": "ranked", + "total_length": 212, + "user_id": 11, + "version": "Wanpachi's Hard", + "accuracy": 6, + "ar": 8, + "bpm": 240, + "convert": false, + "count_circles": 218, + "count_sliders": 359, + "count_spinners": 2, + "cs": 3, + "deleted_at": null, + "drain": 5, + "hit_length": 180, + "is_scoreable": true, + "last_updated": "2022-07-21T06:02:13Z", + "mode_int": 0, + "passcount": 1, + "playcount": 9, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3631491", + "checksum": "5111a3da1c545d05ff4136802ad76590", + "beatmapset": { + "anime_cover": false, + "artist": "BUTAOTOME", + "artist_unicode": "\u8c5a\u4e59\u5973", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/cover.jpg?1658383350", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/cover@2x.jpg?1658383350", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/card.jpg?1658383350", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/card@2x.jpg?1658383350", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/list.jpg?1658383350", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/list@2x.jpg?1658383350", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/slimcover.jpg?1658383350", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/slimcover@2x.jpg?1658383350" + }, + "creator": "Deca", + "favourite_count": 8, + "genre_id": 4, + "hype": null, + "id": 1681275, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 28, + "preview_url": "\/\/b.ppy.sh\/preview\/1681275.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Shinsan Game", + "title_unicode": "\u8f9b\u9178\u30b2\u30fc\u30e0", + "track_id": null, + "user_id": 11, + "video": false, + "bpm": 240, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-21T06:02:11Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T20:04:46Z", + "rating": 0, + "storyboard": false, + "submitted_date": "2022-01-23T05:51:07Z", + "tags": "evilelvis buta-otome hardships game comp ranko doubt \u30e9\u30f3\u30b3 jounzan natteke desu aragon lasse amb1d3x hishiro chizuru wanpachi some hero heroine bongo \u30c0\u30a6\u30c8 japanese rock", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 968, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1703042, + "difficulty_rating": 2.36513, + "id": 3658033, + "mode": "osu", + "status": "ranked", + "total_length": 199, + "user_id": 11, + "version": "Jon's Normal", + "accuracy": 4, + "ar": 5, + "bpm": 184, + "convert": false, + "count_circles": 125, + "count_sliders": 237, + "count_spinners": 1, + "cs": 4, + "deleted_at": null, + "drain": 4, + "hit_length": 184, + "is_scoreable": true, + "last_updated": "2022-07-24T19:26:15Z", + "mode_int": 0, + "passcount": 0, + "playcount": 0, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3658033", + "checksum": "e37bb04f424cfdd58481d05a61f7f642", + "beatmapset": { + "anime_cover": false, + "artist": "yuchaP", + "artist_unicode": "\u3086\u3061\u3083P", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/cover.jpg?1658690797", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/cover@2x.jpg?1658690797", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/card.jpg?1658690797", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/card@2x.jpg?1658690797", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/list.jpg?1658690797", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/list@2x.jpg?1658690797", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/slimcover.jpg?1658690797", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/slimcover@2x.jpg?1658690797" + }, + "creator": "Nevo", + "favourite_count": 3, + "genre_id": 4, + "hype": null, + "id": 1703042, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 7, + "preview_url": "\/\/b.ppy.sh\/preview\/1703042.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Pokerface", + "title_unicode": "\u30dd\u30fc\u30ab\u30fc\u30d5\u30a7\u30a4\u30b9", + "track_id": null, + "user_id": 11, + "video": true, + "bpm": 184, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-24T19:26:14Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T19:22:20Z", + "rating": 0, + "storyboard": false, + "submitted_date": "2022-02-19T21:39:17Z", + "tags": "vocaloid japanese cover utaite gumi len fast rvmathew jonarwhal rock jrock j-rock kaichi hnd", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "exit": [ + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 619, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1789487, + "difficulty_rating": 1.98075, + "id": 3666654, + "mode": "osu", + "status": "ranked", + "total_length": 88, + "user_id": 11, + "version": "ckharv's Easy", + "accuracy": 2, + "ar": 3, + "bpm": 180, + "convert": false, + "count_circles": 77, + "count_sliders": 59, + "count_spinners": 0, + "cs": 3, + "deleted_at": null, + "drain": 2, + "hit_length": 87, + "is_scoreable": true, + "last_updated": "2022-07-25T00:02:22Z", + "mode_int": 0, + "passcount": 9, + "playcount": 22, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3666654", + "checksum": "da748165d116b6dcbb2ff6e064cbf5bf", + "beatmapset": { + "anime_cover": false, + "artist": "Satono Diamond (CV: Tachibana Hina), Kitasan Black (CV: Yano Hinaki)", + "artist_unicode": "\u30b5\u30c8\u30ce\u30c0\u30a4\u30e4\u30e2\u30f3\u30c9 (CV\uff1a\u7acb\u82b1\u65e5\u83dc), \u30ad\u30bf\u30b5\u30f3\u30d6\u30e9\u30c3\u30af (CV\uff1a\u77e2\u91ce\u5983\u83dc\u559c)", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/cover.jpg?1658707359", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/cover@2x.jpg?1658707359", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/card.jpg?1658707359", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/card@2x.jpg?1658707359", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/list.jpg?1658707359", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/list@2x.jpg?1658707359", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/slimcover.jpg?1658707359", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/slimcover@2x.jpg?1658707359" + }, + "creator": "Zekk", + "favourite_count": 9, + "genre_id": 5, + "hype": null, + "id": 1789487, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 114, + "preview_url": "\/\/b.ppy.sh\/preview\/1789487.mp3", + "source": "\u30a6\u30de\u5a18 \u30d7\u30ea\u30c6\u30a3\u30fc\u30c0\u30fc\u30d3\u30fc", + "spotlight": false, + "status": "ranked", + "title": "Ambitious World (PV Size)", + "title_unicode": "Ambitious World (PV Size)", + "track_id": null, + "user_id": 11, + "video": false, + "bpm": 180, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-25T00:02:21Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T19:24:15Z", + "rating": 10, + "storyboard": false, + "submitted_date": "2022-06-19T22:22:08Z", + "tags": "horse girls uma musume pretty derby japanese pop video game anime kowari ckharv satonodiamond nanoya koldnoodl", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 239, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1811717, + "difficulty_rating": 2.29139, + "id": 3716286, + "mode": "osu", + "status": "ranked", + "total_length": 125, + "user_id": 11, + "version": "Xahlt's Bot Diff", + "accuracy": 4.5, + "ar": 5.5, + "bpm": 180.55, + "convert": false, + "count_circles": 113, + "count_sliders": 130, + "count_spinners": 0, + "cs": 3.3, + "deleted_at": null, + "drain": 3, + "hit_length": 116, + "is_scoreable": true, + "last_updated": "2022-07-24T18:59:31Z", + "mode_int": 0, + "passcount": 0, + "playcount": 1, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3716286", + "checksum": "fe662e406ce708e33dd6264f59c6f9cd", + "beatmapset": { + "anime_cover": false, + "artist": "Porter Robinson", + "artist_unicode": "Porter Robinson", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/cover.jpg?1658689187", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/cover@2x.jpg?1658689187", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/card.jpg?1658689187", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/card@2x.jpg?1658689187", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/list.jpg?1658689187", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/list@2x.jpg?1658689187", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/slimcover.jpg?1658689187", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/slimcover@2x.jpg?1658689187" + }, + "creator": "Sotarks", + "favourite_count": 29, + "genre_id": 5, + "hype": null, + "id": 1811717, + "language_id": 2, + "nsfw": false, + "offset": 0, + "play_count": 12, + "preview_url": "\/\/b.ppy.sh\/preview\/1811717.mp3", + "source": "League of Legends", + "spotlight": false, + "status": "ranked", + "title": "Everything Goes On (Star Guardian Version) (Sped Up Ver.)", + "title_unicode": "Everything Goes On (Star Guardian Version) (Sped Up Ver.)", + "track_id": null, + "user_id": 11, + "video": true, + "bpm": 180.55, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-24T18:59:29Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-12T20:24:15Z", + "rating": 9.88889, + "storyboard": false, + "submitted_date": "2022-07-21T19:32:53Z", + "tags": "speed 2022 official music video red dog culture house riot games english electronic pop male vocals vocalist edit banter pepekcz xahlt kuon- gweon sua", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 18, + 9, + 9, + 0, + 0, + 9, + 0, + 9, + 0, + 0, + 9, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 406, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + } +] \ No newline at end of file diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs index 07d0fe6ed9..4787b195b1 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Intro; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; using osu.Game.Tests.Visual.Multiplayer; @@ -26,7 +27,7 @@ namespace osu.Game.Tests.Visual.Matchmaking { base.SetUpSteps(); - AddStep("load screen", () => LoadScreen(new ScreenIntro())); + AddStep("load screen", () => LoadScreen(new ScreenIntro(MatchmakingPoolType.QuickPlay))); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 84e0e499fa..9f7930d09a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -277,7 +277,7 @@ namespace osu.Game.Tests.Visual.Online public void TestSelectedModsDontAffectStatistics() { AddStep("show map", () => overlay.ShowBeatmapSet(getBeatmapSet())); - AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR displayed as 7", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((7.0f, 7.0f))); AddStep("set AR10 diff adjust", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust @@ -285,7 +285,7 @@ namespace osu.Game.Tests.Visual.Online ApproachRate = { Value = 10 } } }); - AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR still displayed as 7", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((7.0f, 7.0f))); } [Test] diff --git a/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs b/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs new file mode 100644 index 0000000000..f56b7a7725 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public abstract partial class RankedPlayTestScene : MultiplayerTestScene + { + /// + /// Returns 5 sample s. + /// + protected static APIBeatmap[] GetSampleBeatmaps() + { + using var resourceStream = TestResources.OpenResource("Requests/api-beatmaps-rankedplay.json"); + using var reader = new StreamReader(resourceStream); + + return JsonConvert.DeserializeObject(reader.ReadToEnd())!; + } + + /// + /// A request handler that will resolve api requests to any beatmaps provided by . + /// + public class BeatmapRequestHandler + { + public readonly APIBeatmap[] Beatmaps = GetSampleBeatmaps(); + + public bool HandleRequest(APIRequest request) + { + switch (request) + { + case GetBeatmapRequest beatmapRequest: + var beatmap = Beatmaps.FirstOrDefault(it => it.OnlineID == beatmapRequest.OnlineID); + + if (beatmap != null) + { + beatmapRequest.TriggerSuccess(beatmap); + return true; + } + + break; + + case GetBeatmapsRequest beatmapsRequest: + beatmapsRequest.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = beatmapsRequest + .BeatmapIds + .Select(id => Beatmaps.FirstOrDefault(it => it.OnlineID == id)) + .ToList() + }); + + return true; + } + + return false; + } + } + + public class RevealedRankedPlayCardWithPlaylistItem : RankedPlayCardWithPlaylistItem + { + public RevealedRankedPlayCardWithPlaylistItem(APIBeatmap beatmap, RankedPlayCardItem? card = null) + : base(card ?? new RankedPlayCardItem()) + { + PlaylistItem.Value = new MultiplayerPlaylistItem(new PlaylistItem(beatmap)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs new file mode 100644 index 0000000000..f3e39796ac --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneDiscardScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs new file mode 100644 index 0000000000..8a7f22c822 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneEndedScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + } + + [Test] + public void TestVictory() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.WinningUserId = API.LocalUser.Value.OnlineID; + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1520; + s.Users[2].RatingAfter = 1480; + }).WaitSafely()); + } + + [Test] + public void TestDefeat() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.WinningUserId = 2; + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1480; + s.Users[2].RatingAfter = 1520; + }).WaitSafely()); + } + + [Test] + public void TestDraw() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1480; + s.Users[2].RatingAfter = 1520; + }).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs new file mode 100644 index 0000000000..125cfd9781 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneGameplayWarmupScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var beatmap = new TestBeatmap(Ruleset.Value).BeatmapInfo; + beatmap.StarRating = 2; + + var room = CreateDefaultRoom(MatchType.RankedPlay); + room.Playlist = + [ + new PlaylistItem(beatmap) + { + RulesetID = Ruleset.Value.OnlineID + } + ]; + + JoinRoom(room); + }); + + WaitForJoined(); + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + AddStep("play card", () => MultiplayerClient.PlayCard(new RankedPlayCardItem())); + + AddStep("set warmup state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.GameplayWarmup).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs new file mode 100644 index 0000000000..304b6c2238 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs @@ -0,0 +1,140 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneHandReplay : MultiplayerTestScene + { + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + private TestHandReplayRecorder recorder = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("setup", () => + { + var cards = Enumerable.Range(0, 5) + .Select(_ => new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())) + .ToArray(); + + Children = + [ + playerHand = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + SelectionMode = HandSelectionMode.Multiple + }, + opponentHand = new OpponentHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new HandReplayPlayer(API.LocalUser.Value.OnlineID, opponentHand), + recorder = new TestHandReplayRecorder(playerHand) + { + FlushInterval = flushInterval, + RecordInterval = recordInterval, + } + ]; + + foreach (var card in cards) + { + playerHand.AddCard(card); + opponentHand.AddCard(card); + } + }); + } + + private double flushInterval = 1000; + private double recordInterval = 25; + private double fixedLatency; + private double maxLatency; + + [Test] + public void TestCardHandReplay() + { + AddSliderStep("record interval", 0.0, 1000.0, 25.0, value => + { + recordInterval = value; + recreateRecorder(); + }); + AddSliderStep("flush interval", 0.0, 5000.0, 1000.0, value => + { + flushInterval = value; + recreateRecorder(); + }); + AddSliderStep("latency", 0.0, 5000.0, 0.0, value => + { + fixedLatency = value; + recreateRecorder(); + }); + AddSliderStep("randomize latency", 0.0, 5000.0, 0.0, value => + { + maxLatency = value; + recreateRecorder(); + }); + } + + private void recreateRecorder() + { + if (recorder.IsNotNull()) + { + Remove(recorder, true); + Add(recorder = new TestHandReplayRecorder(playerHand) + { + FlushInterval = flushInterval, + RecordInterval = recordInterval, + FixedLatency = fixedLatency, + RandomLatency = maxLatency, + }); + } + } + + private partial class TestHandReplayRecorder(PlayerHandOfCards handOfCards) : HandReplayRecorder(handOfCards) + { + private double lastSendTime; + + public double FixedLatency; + + public double RandomLatency; + + protected override void Flush(RankedPlayCardHandReplayFrame[] frames) + { + double sendTime = Math.Max(lastSendTime, Time.Current + FixedLatency + RNG.NextDouble(RandomLatency)); + + lastSendTime = sendTime; + + Scheduler.AddDelayed(() => base.Flush(frames), sendTime - Time.Current); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs new file mode 100644 index 0000000000..05ca5673ff --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro; +using osu.Game.Tests.Visual.Matchmaking; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneIntroScreen : MatchmakingTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + IntroScreen introScreen = null!; + + AddStep("Add screen", () => Child = introScreen = new IntroScreen()); + + AddStep("play animation", () => introScreen.PlayIntroSequence( + new UserWithRating(new APIUser + { + Id = 2, + Username = "User 1", + CoverUrl = "https://assets.ppy.sh/user-profile-covers/13845312/53e4eda7ad3ce41f0990c041179d8ab5d553fef988835f346a8d8da0482506ec.png" + }, 1234), + new UserWithRating(new APIUser + { + Id = 3, + Username = "User 2", + CoverUrl = "https://assets.ppy.sh/user-profile-covers/14102976/10144df2f1c6fb2101726e0f89087a6061bc75755d88e59a9faf2c84034f2c71.jpeg" + }, 1234), + 6.3f + )); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs new file mode 100644 index 0000000000..838a49d255 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneOpponentPickScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs new file mode 100644 index 0000000000..042a56adf8 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestScenePickScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs new file mode 100644 index 0000000000..a567079e20 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using Humanizer; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestScenePlayerCardHand : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private PlayerHandOfCards handOfCards = null!; + + [BackgroundDependencyLoader] + private void load() + { + 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())); + }); + AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single); + + AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + + AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + + AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + } + + [Test] + public void TestMultiSelectionMode() + { + AddStep("add cards", () => + { + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple); + + AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + + AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item, handOfCards.Cards.ElementAt(1).Item])); + + AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + } + + [Test] + public void TestCardCount() + { + for (int i = 1; i <= 8; i++) + { + int numCards = i; + + AddStep($"{i} {"cards".Pluralize(i == 1)}", () => + { + handOfCards.Clear(); + + for (int j = 0; j < numCards; j++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + } + } + + [Test] + public void TestKeyboardSelectionSingleSelection() + { + bool playActionTriggered = false; + + AddStep("add cards", () => + { + playActionTriggered = false; + handOfCards.PlayCardAction = () => playActionTriggered = true; + + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single); + + for (int i = 0; i < 5; i++) + { + int i1 = i; + Key key = Key.Number1 + i; + + AddStep($"key {i + 1}", () => InputManager.Key(key)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(i1).Item])); + } + + AddStep("right arrow", () => InputManager.Key(Key.Right)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + + AddStep("right arrow", () => InputManager.Key(Key.Right)); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + + AddStep("left arrow", () => InputManager.Key(Key.Left)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + + AddStep("left arrow", () => InputManager.Key(Key.Left)); + AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(^1).Item])); + + AddStep("space", () => InputManager.Key(Key.Space)); + AddAssert("play action triggered", () => playActionTriggered); + } + + [Test] + public void TestKeyboardSelectionMultiSelection() + { + AddStep("add cards", () => + { + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple); + + for (int i = 0; i < 5; i++) + { + int i1 = i; + Key key = Key.Number1 + i; + + AddStep($"key {i + 1}", () => InputManager.Key(key)); + AddAssert("card hovered", () => handOfCards.Cards.ElementAt(i1).CardHovered); + + AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + AddStep("space", () => InputManager.Key(Key.Space)); + AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs new file mode 100644 index 0000000000..a181abc1c6 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayBackground : OsuTestScene + { + private readonly RankedPlayBackground background; + + private readonly Bindable gradientOuter = new Bindable(Color4Extensions.FromHex("AC6D97")); + private readonly Bindable gradientInner = new Bindable(Color4Extensions.FromHex("544483")); + private readonly Bindable dots = new Bindable(Color4Extensions.FromHex("D56CF6")); + + public TestSceneRankedPlayBackground() + { + Children = + [ + background = new RankedPlayBackground { RelativeSizeAxes = Axes.Both }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = gradientOuter, + }, + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = gradientInner, + }, + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = dots, + } + ] + } + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + gradientOuter.BindValueChanged(e => background.GradientOutside = e.NewValue, true); + gradientInner.BindValueChanged(e => background.GradientInside = e.NewValue, true); + dots.BindValueChanged(e => background.DotsColour = e.NewValue, true); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs new file mode 100644 index 0000000000..54fc56a4f9 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs @@ -0,0 +1,222 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayCard : RankedPlayTestScene + { + protected override Container Content { get; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Cached] + private readonly CardDetailsOverlayContainer overlayContainer; + + [Cached] + private readonly SongPreviewParticleContainer particleContainer; + + private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler(); + + public TestSceneRankedPlayCard() + { + base.Content.AddRange(new Drawable[] + { + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }, + overlayContainer = new CardDetailsOverlayContainer(), + particleContainer = new SongPreviewParticleContainer(), + }); + } + + [Test] + public void TestCards() + { + AddStep("add cards", () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Width = 800f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + }; + + for (int i = 0; i < 10; i++) + { + var beatmap = CreateAPIBeatmap(); + + beatmap.BeatmapSet!.Ratings = Enumerable.Range(0, 11).ToArray(); + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(x => x % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(x => x % 12 - 6).ToArray(), + }; + + beatmap.StarRating = i + 1; + + flow.Add(new RankedPlayCardContent(beatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + }); + } + }); + } + + [Test] + public void TestCardHand() + { + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("add cards", () => + { + PlayerHandOfCards handOfCards; + + Child = handOfCards = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + SelectionMode = HandSelectionMode.Single + }; + + foreach (var beatmap in requestHandler.Beatmaps) + { + handOfCards.AddCard(new RevealedRankedPlayCardWithPlaylistItem(beatmap)); + } + }); + } + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Test] + public void TestRulesets() + { + var rulesets = rulesetStore.AvailableRulesets.Where(it => it.OnlineID >= 0); + + foreach (var ruleset in rulesets) + { + AddStep(ruleset.ShortName, () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Width = 800f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + }; + + for (int i = 0; i < 10; i++) + { + var beatmap = CreateAPIBeatmap(ruleset); + + beatmap.BeatmapSet!.Ratings = Enumerable.Range(0, 11).ToArray(); + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(x => x % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(x => x % 12 - 6).ToArray(), + }; + + beatmap.StarRating = i + 1; + + flow.Add(new RankedPlayCardContent(beatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + }); + } + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs new file mode 100644 index 0000000000..f467540cb8 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayCornerPiece : MultiplayerTestScene + { + private readonly Bindable visibility = new Bindable(Visibility.Visible); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add children", () => + { + Children = + [ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + State = { BindTarget = visibility }, + Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.TopRight) + { + State = { BindTarget = visibility }, + Child = new RankedPlayUserDisplay(2, Anchor.TopRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + } + }, + ]; + }); + } + + [Test] + public void TestCornerPieces() + { + AddStep("show", () => visibility.Value = Visibility.Visible); + AddStep("hide", () => visibility.Value = Visibility.Hidden); + AddSliderStep("health", 0, 1_000_000, 1_000_000, value => + { + foreach (var d in this.ChildrenOfType()) + { + d.Health.Value = value; + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs new file mode 100644 index 0000000000..6446ec8f08 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayScreen : RankedPlayTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + } + + [Test] + public void TestIntroStage() + { + AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely()); + } + + [Test] + public void TestDiscardCardsStage() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + AddWaitStep("wait", 3); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep($"click card {i2}", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(i2)); + InputManager.Click(MouseButton.Left); + }); + } + + AddWaitStep("wait", 3); + + AddStep("click discard button", () => + { + var button = screen.ChildrenOfType().Single(it => it.Name == "Discard Button"); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddWaitStep("wait", 13); + AddStep("set finish discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.FinishCardDiscard).WaitSafely()); + } + + [Test] + public void TestAddRemoveCards() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + for (int i = 0; i < 3; i++) + AddStep("add card", () => MultiplayerClient.RankedPlayAddCards([new RankedPlayCardItem()]).WaitSafely()); + + for (int i = 0; i < 3; i++) + AddStep("remove card", () => MultiplayerClient.RankedPlayRemoveCards(hand => [hand[0]]).WaitSafely()); + } + + [Test] + public void TestRevealCards() + { + var requestHandler = new BeatmapRequestHandler(); + + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem + { + ID = i2, + BeatmapID = requestHandler.Beatmaps[i2].OnlineID + }).WaitSafely()); + } + } + + [Test] + public void TestPlayCardDirect() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + AddWaitStep("wait", 3); + AddStep("play card", () => MultiplayerClient.PlayCard(hand => hand[0]).WaitSafely()); + } + + [Test] + public void TestDiscardCardsDirect() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + AddWaitStep("wait", 3); + AddStep("discard cards", () => MultiplayerClient.DiscardCards(hand => hand.Take(3)).WaitSafely()); + AddWaitStep("wait", 13); + AddStep("set finish discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.FinishCardDiscard).WaitSafely()); + } + + [Test] + public void TestPlayStage() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + AddUntilStep("wait until cards are present", () => this.ChildrenOfType().Count() == 5); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep($"click card {i2}", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(i2)); + InputManager.Click(MouseButton.Left); + }); + } + + AddWaitStep("wait", 3); + + AddStep("click play button", () => + { + var button = screen + .ChildrenOfType() + .First(it => it.Selected) + .ChildrenOfType() + .First(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + } + + [Test] + public void TestOtherPlaysCard() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(2, hand => hand[0]).WaitSafely()); + AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealUserCard(2, hand => hand[0], new MultiplayerPlaylistItem + { + ID = 0, + BeatmapID = 0 + }).WaitSafely()); + } + + [Test] + public void TestHealthChange() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("change player 1 health", () => MultiplayerClient.RankedPlayChangeUserState(MultiplayerClient.LocalUser!.UserID, state => state.Life = 250_000).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("change player 2 health", () => MultiplayerClient.RankedPlayChangeUserState(2, state => state.Life = 250_000).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs new file mode 100644 index 0000000000..f7cc9885c7 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayUserDisplay : MultiplayerTestScene + { + private readonly BindableInt health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add display", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + } + + [Test] + public void TesUserDisplay() + { + AddStep("blue color scheme", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + + AddStep("red color scheme", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Red) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + + AddSliderStep("health", 0, 1_000_000, 1_000_000, value => health.Value = value); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs new file mode 100644 index 0000000000..00eb637512 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs @@ -0,0 +1,231 @@ +// 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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneResultsScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + setupRequestHandler(); + } + + [Test] + public void TestBasic() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456, + OldLife = 500_000, + NewLife = 500_000 - 123_456, + }; + + userInfo.Life = 500_000 - 123_456; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + [Test] + public void TestMultiplier() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + state.DamageMultiplier = 2; + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456 * 2, + OldLife = 1_000_000, + NewLife = 1_000_000 - 123_456 * 2, + }; + + userInfo.Life = 1_000_000 - 123_456 * 2; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + [Test] + public void TestMissingScores() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = ((DummyAPIAccess)API).HandleRequest; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case IndexPlaylistScoresRequest index: + index.TriggerSuccess(new IndexedMultiplayerScores()); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + state.DamageMultiplier = 2; + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456 * 2, + OldLife = 1_000_000, + NewLife = 1_000_000 - 123_456 * 2, + }; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = ((DummyAPIAccess)API).HandleRequest; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + foreach (int userId in new[] { 2, API.LocalUser.Value.OnlineID }) + { + result.Scores.Add(new MultiplayerScore + { + ID = userId, + Accuracy = RNG.NextSingle(), + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = RNG.Next(1000), + TotalScore = userId == 2 ? 750_000 : 750_000 - 123_456, + Statistics = new Dictionary + { + [HitResult.Miss] = 1, + [HitResult.Meh] = 50, + [HitResult.Ok] = 100, + [HitResult.Good] = 200, + [HitResult.Great] = 300, + [HitResult.Perfect] = 320, + [HitResult.SmallTickHit] = 50, + [HitResult.SmallTickMiss] = 25, + [HitResult.LargeTickHit] = 100, + [HitResult.LargeTickMiss] = 50, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50 + }, + MaximumStatistics = new Dictionary + { + [HitResult.Perfect] = 971, + [HitResult.SmallTickHit] = 75, + [HitResult.LargeTickHit] = 150, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50, + }, + User = new APIUser + { + Id = userId, + Username = $"user {userId}", + } + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs new file mode 100644 index 0000000000..363082f668 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneSongPreview : RankedPlayTestScene + { + private readonly Bindable previewEnabled = new BindableBool(true); + + private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("add cards", () => + { + PlayerHandOfCards handOfCards; + + Child = handOfCards = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(0.5f), + }; + + foreach (var beatmap in requestHandler.Beatmaps.Take(3)) + { + handOfCards.AddCard(new RevealedRankedPlayCardWithPlaylistItem(beatmap), handCard => + { + handCard.Card.SongPreviewEnabled.BindTarget = previewEnabled; + }); + } + }); + + AddUntilStep("load tracks", () => this.ChildrenOfType().All(card => card.PreviewTrackLoaded)); + } + + [Test] + public void TestSongPreview() + { + AddStep("move mouse to first card", () => InputManager.MoveMouseTo(getCard(0))); + + AddAssert("first track running", () => getCard(0).PreviewTrackRunning); + AddAssert("only one track running", () => this.ChildrenOfType().Count(c => c.PreviewTrackRunning) == 1); + + AddStep("move mouse to second card", () => InputManager.MoveMouseTo(getCard(1))); + + AddAssert("second track running", () => getCard(1).PreviewTrackRunning); + AddAssert("only one track running", () => this.ChildrenOfType().Count(c => c.PreviewTrackRunning) == 1); + + AddStep("disable preview", () => previewEnabled.Value = false); + + AddAssert("no tracks running", () => !this.ChildrenOfType().Any(c => c.PreviewTrackRunning)); + + AddStep("move mouse to third card", () => InputManager.MoveMouseTo(getCard(2))); + + AddAssert("no tracks running", () => !this.ChildrenOfType().Any(c => c.PreviewTrackRunning)); + + AddStep("enable preview", () => previewEnabled.Value = true); + + AddAssert("third track running", () => getCard(2).PreviewTrackRunning); + } + + private RankedPlayCard getCard(int index) => this.ChildrenOfType().ElementAt(index); + } +} diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index 961990a1bd..c22f4fcdf0 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.cs @@ -32,8 +32,12 @@ namespace osu.Game.Audio private void load() { Track = GetTrack(); + if (Track != null) + { Track.Completed += Stop; + Track.Looping = looping; + } } /// @@ -56,6 +60,23 @@ namespace osu.Game.Audio /// public bool IsRunning => Track?.IsRunning ?? false; + private bool looping; + + /// + /// Whether the track should loop. + /// + public bool Looping + { + get => looping; + set + { + looping = value; + + if (Track != null) + Track.Looping = looping; + } + } + private ScheduledDelegate? startDelegate; /// diff --git a/osu.Game/Audio/SamplePlaybackHelper.cs b/osu.Game/Audio/SamplePlaybackHelper.cs new file mode 100644 index 0000000000..8c7168e7ed --- /dev/null +++ b/osu.Game/Audio/SamplePlaybackHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio.Sample; +using osu.Framework.Utils; + +namespace osu.Game.Audio +{ + public static class SamplePlaybackHelper + { + /// + /// Plays the provided with a randomised pitch. + /// + /// The to be played. + /// The amount of pitch variation to allow. + /// The that was used for playback. + public static SampleChannel? PlayWithRandomPitch(Sample? sample, double pitchVariation = 0.2f) + { + var chan = sample?.GetChannel(); + if (chan == null) + return null; + + chan.Frequency.Value = RNG.NextDouble(1 - pitchVariation, 1 + pitchVariation); + chan.Play(); + + return chan; + } + + /// + /// Plays a random sample from the given array, with a randomised pitch. + /// + /// An array of to play. + /// The amount of pitch variation to allow. + /// The that was used for playback. + public static SampleChannel? PlayWithRandomPitch(Sample?[]? samples, double pitchVariation = 0.2f) => + PlayWithRandomPitch(samples?[RNG.Next(0, samples.Length)], pitchVariation); + } +} diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index c851119274..2bce75c010 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -69,6 +69,11 @@ namespace osu.Game.Localisation /// public static LocalisableString QuickPlay => new TranslatableString(getKey(@"quick_play"), @"quick play"); + /// + /// "ranked play" + /// + public static LocalisableString RankedPlay => new TranslatableString(getKey(@"ranked_play"), @"ranked play"); + /// /// "A few important words from your dev team!" /// diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index f4f4165c7f..efcc14a5c1 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -115,5 +115,36 @@ namespace osu.Game.Online.Leaderboards return Color4Extensions.FromHex(@"CC3333"); } } + + public static string GetLegacyRankTextureName(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.XH: + return "ranking-XH"; + + case ScoreRank.SH: + return "ranking-SH"; + + case ScoreRank.X: + return "ranking-X"; + + case ScoreRank.S: + return "ranking-S"; + + case ScoreRank.A: + return "ranking-A"; + + case ScoreRank.B: + return "ranking-B"; + + case ScoreRank.C: + return "ranking-C"; + + default: + case ScoreRank.D: + return "ranking-D"; + } + } } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs index edffa4ec23..de53447097 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs @@ -54,6 +54,6 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking /// /// All rounds have completed. Users may still be chatting. /// - Ended + Ended, } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 04d7d287d1..ff974e2e6d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -19,15 +19,22 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer, IMatchmakingServer, IMatchmakingClient + public abstract partial class MultiplayerClient : + Component, + IMultiplayerClient, IMultiplayerRoomServer, + IMatchmakingServer, IMatchmakingClient, + IRankedPlayClient, IRankedPlayServer { public Action? PostNotification { protected get; set; } @@ -131,6 +138,10 @@ namespace osu.Game.Online.Multiplayer public event Action? MatchmakingItemDeselected; public event Action? MatchRoomStateChanged; + public event Action? RankedPlayCardAdded; + public event Action? RankedPlayCardRemoved; + public event Action? RankedPlayCardPlayed; + public event Action? UserVotedToSkipIntro; public event Action? VoteToSkipIntroPassed; @@ -197,6 +208,7 @@ namespace osu.Game.Online.Multiplayer protected Room? APIRoom { get; private set; } private readonly Queue pendingRequests = new Queue(); + private readonly Dictionary cardsWithPlaylistItems = []; [BackgroundDependencyLoader] private void load() @@ -331,6 +343,7 @@ namespace osu.Game.Online.Multiplayer APIRoom = null; Room = null; PlayingUserIds.Clear(); + cardsWithPlaylistItems.Clear(); RoomUpdated?.Invoke(); }); @@ -1135,6 +1148,62 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + public abstract Task DiscardCards(RankedPlayCardItem[] cards); + + public abstract Task PlayCard(RankedPlayCardItem card); + + Task IRankedPlayClient.RankedPlayCardAdded(int userId, RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardAdded?.Invoke(userId, GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardRemoved(int userId, RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardRemoved?.Invoke(userId, GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardRevealed(RankedPlayCardItem card, MultiplayerPlaylistItem item) + { + handleRoomRequest(() => + { + GetCardWithPlaylistItem(card).PlaylistItem.Value = item; + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardPlayed(RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardPlayed?.Invoke(GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + public RankedPlayCardWithPlaylistItem GetCardWithPlaylistItem(RankedPlayCardItem card) + { + if (cardsWithPlaylistItems.TryGetValue(card, out var existing)) + return existing; + + return cardsWithPlaylistItems[card] = new RankedPlayCardWithPlaylistItem(card); + } + public abstract Task GetMatchmakingPoolsOfType(MatchmakingPoolType type); public abstract Task MatchmakingJoinLobby(); diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 53e3edbe6b..6d722807aa 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -15,6 +15,8 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; using osu.Game.Localisation; using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; namespace osu.Game.Online.Multiplayer { @@ -82,6 +84,11 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected); connection.On(nameof(IMatchmakingClient.MatchmakingItemDeselected), ((IMatchmakingClient)this).MatchmakingItemDeselected); + connection.On(nameof(IRankedPlayClient.RankedPlayCardAdded), ((IRankedPlayClient)this).RankedPlayCardAdded); + connection.On(nameof(IRankedPlayClient.RankedPlayCardRemoved), ((IRankedPlayClient)this).RankedPlayCardRemoved); + connection.On(nameof(IRankedPlayClient.RankedPlayCardRevealed), ((IRankedPlayClient)this).RankedPlayCardRevealed); + connection.On(nameof(IRankedPlayClient.RankedPlayCardPlayed), ((IRankedPlayClient)this).RankedPlayCardPlayed); + connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); }; @@ -333,6 +340,26 @@ namespace osu.Game.Online.Multiplayer return connector.Disconnect(); } + public override Task DiscardCards(RankedPlayCardItem[] cards) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IRankedPlayServer.DiscardCards), cards); + } + + public override Task PlayCard(RankedPlayCardItem card) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IRankedPlayServer.PlayCard), card); + } + public override Task GetMatchmakingPoolsOfType(MatchmakingPoolType type) { if (!IsConnected.Value) diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 362e442afc..926e824d7f 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -47,7 +47,8 @@ namespace osu.Game.Screens.Menu public Action? OnSolo; public Action? OnSettings; public Action? OnMultiplayer; - public Action? OnMatchmaking; + public Action? OnQuickPlay; + public Action? OnRankedPlay; public Action? OnPlaylists; public Action? OnDailyChallenge; @@ -161,7 +162,11 @@ namespace osu.Game.Screens.Menu { Padding = new MarginPadding { Left = WEDGE_WIDTH } }); - buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.QuickPlay, @"button-daily-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onMatchmaking, Key.Q)); +#if DEBUG + buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.RankedPlay, @"button-daily-select", FontAwesome.Solid.Crown, new Color4(94, 63, 186, 255), onRankedPlay, Key.R)); +#else + buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.QuickPlay, @"button-daily-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onQuickPlay, Key.Q)); +#endif buttonsMulti.ForEach(b => b.VisibleState = ButtonSystemState.Multi); buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, @@ -217,7 +222,7 @@ namespace osu.Game.Screens.Menu OnMultiplayer?.Invoke(); } - private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent) + private void onQuickPlay(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -225,7 +230,18 @@ namespace osu.Game.Screens.Menu return; } - OnMatchmaking?.Invoke(); + OnQuickPlay?.Invoke(); + } + + private void onRankedPlay(MainMenuButton mainMenuButton, UIEvent uiEvent) + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + OnRankedPlay?.Invoke(); } private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index ee734acb60..0820d33622 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -29,6 +29,7 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Matchmaking; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; @@ -159,7 +160,8 @@ namespace osu.Game.Screens.Menu }, OnSolo = loadSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), - OnMatchmaking = joinOrLeaveMatchmakingQueue, + OnQuickPlay = loadQuickPlay, + OnRankedPlay = loadRankedPlay, OnPlaylists = () => this.Push(new Playlists()), OnDailyChallenge = room => { @@ -482,7 +484,9 @@ namespace osu.Game.Screens.Menu private void loadSongSelect() => this.Push(new SoloSongSelect()); - private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro()); + private void loadQuickPlay() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro(MatchmakingPoolType.QuickPlay)); + + private void loadRankedPlay() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro(MatchmakingPoolType.RankedPlay)); private partial class MobileDisclaimerDialog : PopupDialog { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs index 7d630ff986..0f20ec9a54 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Matchmaking; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; @@ -40,28 +41,36 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro [Resolved] private MusicController musicController { get; set; } = null!; + private readonly MatchmakingPoolType poolType; + private Sample? dateWindupSample; private Sample? dateImpactSample; private Sample? beatmapWindupSample; - private Sample? beatmapImpactSample; private SampleChannel? dateWindupChannel; private SampleChannel? dateImpactChannel; private SampleChannel? beatmapWindupChannel; - private SampleChannel? beatmapImpactChannel; private IDisposable? duckOperation; protected override BackgroundScreen CreateBackground() => new MatchmakingBackgroundScreen(colourProvider); - public ScreenIntro() + public ScreenIntro(MatchmakingPoolType poolType) { + this.poolType = poolType; ValidForResume = false; } [BackgroundDependencyLoader] private void load(AudioManager audio) { + string poolTypeName = poolType switch + { + MatchmakingPoolType.QuickPlay => "Quick Play", + MatchmakingPoolType.RankedPlay => "Ranked Play", + _ => throw new ArgumentOutOfRangeException() + }; + InternalChildren = new Drawable[] { introContent = new Container @@ -99,7 +108,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Quick Play", + Text = poolTypeName, Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, Shear = -OsuGame.SHEAR, Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), @@ -115,7 +124,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup"); dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact"); beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup"); - beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact"); } public override void OnEntering(ScreenTransitionEvent e) @@ -194,7 +202,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro Schedule(() => { if (this.IsCurrentScreen()) - this.Push(new ScreenQueue()); + this.Push(new ScreenQueue(poolType)); }); } } @@ -219,12 +227,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro beatmapWindupChannel?.Play(); } - private void playBeatmapImpactSample() - { - beatmapImpactChannel = beatmapImpactSample?.GetChannel(); - beatmapImpactChannel?.Play(); - } - protected override void Dispose(bool isDisposing) { resetAudio(); @@ -236,7 +238,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro dateWindupChannel?.Stop(); dateImpactChannel?.Stop(); beatmapWindupChannel?.Stop(); - beatmapImpactChannel?.Stop(); duckOperation?.Dispose(); } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs index a89700ffe3..7995f72f1a 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue { private const float icon_size = 34; - public readonly Bindable AvailablePools = new Bindable(); + public readonly Bindable AvailablePools = new Bindable([]); public readonly Bindable SelectedPool = new Bindable(); private FillFlowContainer poolFlow = null!; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 9456e56bf9..02e944df9f 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -40,6 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue private BackgroundQueueNotification? backgroundNotification; private bool isBackgrounded; + private MatchmakingPool? lastJoinedPool; protected override void LoadComplete() { @@ -52,6 +54,36 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue client.MatchmakingRoomReady += onMatchmakingRoomReady; } + /// + /// Joins the matchmaking queue. + /// + /// The pool to join. + public void JoinQueue(MatchmakingPool pool) + { + client.MatchmakingJoinQueue(pool.Id).FireAndForget(); + lastJoinedPool = pool; + } + + /// + /// Leaves the matchmaking queue. + /// + public void LeaveQueue() + { + client.MatchmakingLeaveQueue().FireAndForget(); + } + + /// + /// Rejoins the last joined matchmaking queue. + /// + public void RejoinQueue() + { + if (lastJoinedPool != null) + JoinQueue(lastJoinedPool); + } + + /// + /// Moves the matchmaking queue search to the background. + /// public void SearchInBackground() { if (isBackgrounded) @@ -61,6 +93,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue postNotification(); } + /// + /// Moves the matchmaking queue search to the foreground. + /// public void SearchInForeground() { if (!isBackgrounded) @@ -117,7 +152,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue if (backgroundNotification != null) return; - notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this)); + Debug.Assert(lastJoinedPool != null); + notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this, lastJoinedPool.Type)); } private void closeNotifications() @@ -153,13 +189,15 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue private MultiplayerClient client { get; set; } = null!; private readonly QueueController controller; + private readonly MatchmakingPoolType poolType; private Notification? foundNotification; private Sample? matchFoundSample; - public BackgroundQueueNotification(QueueController controller) + public BackgroundQueueNotification(QueueController controller, MatchmakingPoolType poolType) { this.controller = controller; + this.poolType = poolType; } [BackgroundDependencyLoader] @@ -174,7 +212,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue if (s is ScreenIntro || s is ScreenQueue) return; - s.Push(new ScreenIntro()); + s.Push(new ScreenIntro(poolType)); }, [typeof(ScreenIntro), typeof(ScreenQueue)]); // Closed when appropriate by SearchInForeground(). @@ -197,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue client.MatchmakingAcceptInvitation().FireAndForget(); controller.CurrentState.Value = ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom; - performer?.PerformFromScreen(s => s.Push(new ScreenIntro())); + performer?.PerformFromScreen(s => s.Push(new ScreenIntro(invitation.Type))); Close(false); return true; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index 87d50213ee..2d28cafaab 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -29,8 +29,10 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer; using osu.Game.Overlays; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; using osuTK; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue @@ -74,9 +76,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue private readonly IBindable currentState = new Bindable(); - private readonly Bindable availablePools = new Bindable(); + private readonly Bindable availablePools = new Bindable([]); private readonly Bindable selectedPool = new Bindable(); + private readonly MatchmakingPoolType poolType; + private CancellationTokenSource userLookupCancellation = new CancellationTokenSource(); private Sample? enqueueSample; @@ -86,6 +90,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue private SampleChannel? waitingLoopChannel; private ScheduledDelegate? startLoopPlaybackDelegate; + public ScreenQueue(MatchmakingPoolType poolType) + { + this.poolType = poolType; + } + protected override void LoadComplete() { base.LoadComplete(); @@ -95,6 +104,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + new GlobalScrollAdjustsVolume(), cloud = new CloudVisualisation { Y = -100, @@ -151,7 +161,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue private async Task populateAvailablePools() { - MatchmakingPool[] pools = await client.GetMatchmakingPoolsOfType(MatchmakingPoolType.QuickPlay).ConfigureAwait(false); + MatchmakingPool[] pools = await client.GetMatchmakingPoolsOfType(poolType).ConfigureAwait(false); Schedule(() => { @@ -228,7 +238,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue case MatchmakingScreenState.PendingAccept: case MatchmakingScreenState.AcceptedWaitingForRoom: - client.MatchmakingLeaveQueue().FireAndForget(); + controller.LeaveQueue(); return true; case MatchmakingScreenState.InRoom: @@ -280,7 +290,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue Action = () => { Debug.Assert(selectedPool.Value != null); - client.MatchmakingJoinQueue(selectedPool.Value.Id).FireAndForget(); + controller.JoinQueue(selectedPool.Value); }, Text = "Begin queueing", } @@ -317,7 +327,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue Origin = Anchor.Centre, Width = 200, Text = "Stop queueing", - Action = () => client.MatchmakingLeaveQueue().FireAndForget() + Action = () => controller.LeaveQueue() } } }; @@ -383,7 +393,22 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue }; using (BeginDelayedSequence(2000)) - Schedule(() => this.Push(new ScreenMatchmaking(client.Room!))); + { + Schedule(() => + { + switch (poolType) + { + case MatchmakingPoolType.QuickPlay: + this.Push(new ScreenMatchmaking(client.Room!)); + break; + + case MatchmakingPoolType.RankedPlay: + this.Push(new RankedPlayScreen(client.Room!)); + break; + } + }); + } + break; default: diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs new file mode 100644 index 0000000000..decbccc6bf --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class CardDetailsOverlayContainer + { + private partial class UserTagSection : CompositeDrawable + { + public IEnumerable Tags + { + set + { + Debug.Assert(LoadState >= LoadState.Ready); + + tagFlow.ChildrenEnumerable = value.Select(tag => new DrawableUserTag(tag)); + this.FadeTo(tagFlow.Children.Count > 0 ? 1 : 0); + } + } + + private FillFlowContainer tagFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding(10); + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = + [ + new OsuSpriteText + { + Text = "User Tags", + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + }, + tagFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(4) + } + ] + }; + } + } + + private partial class DrawableUserTag(UserTag tag) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 3; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = + [ + new Container + { + AutoSizeAxes = Axes.Both, + Alpha = tag.GroupName != null ? 1 : 0, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Gray6, + }, + new OsuSpriteText + { + Text = tag.GroupName ?? "", + Padding = new MarginPadding { Left = 5, Right = 3 }, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + } + ] + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Gray2, + }, + new OsuSpriteText + { + Text = tag.DisplayName, + Padding = new MarginPadding { Left = 5, Right = 3 }, + Font = OsuFont.GetFont(size: 12), + } + ] + }, + ] + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs new file mode 100644 index 0000000000..21b42b981e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs @@ -0,0 +1,146 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Threading; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + [Cached] + public partial class CardDetailsOverlayContainer : Container + { + public double HideDelay { get; set; } = 1000; + + protected override Container Content { get; } + + private readonly CardDetailsOverlay overlay; + + public CardDetailsOverlayContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + Content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + overlay = new CardDetailsOverlay + { + Alpha = 0, + } + ]; + } + + private ScheduledDelegate? hideDelegate; + + public void ShowCardDetails(Drawable targetDrawable, APIBeatmap beatmap) + { + // TODO: remove this once there's more than just tags in the overlay + if (beatmap.GetTopUserTags().Length == 0) + return; + + hideDelegate?.Cancel(); + hideDelegate = Scheduler.AddDelayed(overlay.Hide, HideDelay); + + overlay.TargetDrawable = targetDrawable; + overlay.Beatmap.Value = beatmap; + overlay.Show(); + } + + private partial class CardDetailsOverlay : VisibilityContainer + { + public readonly Bindable Beatmap = new Bindable(); + + public Drawable? TargetDrawable; + + private Container content = null!; + private UserTagSection tagSection = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Width = 200; + Origin = Anchor.CentreRight; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 6, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + Alpha = 0.85f, + }, + tagSection = new UserTagSection() + ] + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(e => + { + if (e.NewValue != null) + populateContent(e.NewValue); + }, true); + } + + private void populateContent(APIBeatmap beatmap) + { + tagSection.Tags = beatmap.GetTopUserTags().Select(it => new UserTag(it.Tag) { VoteCount = { Value = it.VoteCount } }); + } + + private Vector2 targetPosition => TargetDrawable is { } drawable + ? Parent!.ToLocalSpace(drawable.ScreenSpaceDrawQuad.TopLeft) + new Vector2(-20, 0) + // this results essentially a no-op when there's no valid target + : Position; + + private readonly Vector2Spring position = new Vector2Spring + { + NaturalFrequency = 2f, + Response = 0.25f, + Damping = 0.85f + }; + + protected override void Update() + { + base.Update(); + + // Workaround for AutoSizeAxes not working due to content being able to move + Height = content.Height; + + Position = position.Update(Time.Elapsed, targetPosition); + } + + protected override void PopIn() + { + this.FadeIn(300); + + content.MoveToX(-50) + .MoveToX(0, 400, Easing.OutExpo); + + position.Current = position.PreviousTarget = targetPosition; + } + + protected override void PopOut() => this.FadeOut(300); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs new file mode 100644 index 0000000000..6559fd5860 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs @@ -0,0 +1,306 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCard + { + public partial class SongPreviewContainer : Container, IBeatSyncProvider + { + private const double minimum_beat_length = 800; + + public readonly Bindable Enabled = new BindableBool(true); + + public bool TrackLoaded => previewTrack?.TrackLoaded ?? false; + + public bool IsRunning => previewTrack?.IsRunning ?? false; + + protected override Container Content { get; } + + private readonly Bindable trackRunning = new BindableBool(); + private readonly Container overlayLayer; + + private bool shouldBePlaying => Enabled.Value && IsHovered; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private OsuColour osuColour { get; set; } = null!; + + public SongPreviewContainer() + { + InternalChildren = + [ + new PulseContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + Content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + overlayLayer = new Container + { + RelativeSizeAxes = Axes.Both, + } + ] + }, + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(enabled => + { + if (!enabled.NewValue) + { + previewTrack?.Stop(); + return; + } + + if (shouldBePlaying) + { + startPreviewIfAvailable(); + } + }); + } + + private PreviewTrack? previewTrack; + + public void LoadPreview(APIBeatmap beatmap) + { + Debug.Assert(previewTrack == null); + + LoadComponentAsync(previewTrack = previewTrackManager.Get(beatmap.BeatmapSet!), track => + { + AddInternal(track); + + track.Looping = true; + track.Started += onTrackStarted; + track.Stopped += onTrackStopped; + + setupBeatSyncProvider(track, beatmap); + + var cardColours = new RankedPlayCardContent.CardColours(beatmap, osuColour); + + overlayLayer.Add(new RippleVisualization(cardColours.Border) + { + TrackRunning = trackRunning.GetBoundCopy(), + }); + + if (IsHovered) + startPreviewIfAvailable(); + }); + } + + protected override bool OnHover(HoverEvent e) + { + if (shouldBePlaying) + startPreviewIfAvailable(); + + return base.OnHover(e); + } + + private void onTrackStarted() => Schedule(() => trackRunning.Value = true); + + private void onTrackStopped() => Schedule(() => trackRunning.Value = false); + + private void startPreviewIfAvailable() => previewTrack?.Start(); + + #region IBeatSyncProvider implementation + + private readonly PreviewTrackClock beatSyncClock = new PreviewTrackClock(); + private readonly ControlPointInfo controlPoints = new ControlPointInfo(); + + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => ChannelAmplitudes.Empty; + ControlPointInfo IBeatSyncProvider.ControlPoints => controlPoints; + IClock IBeatSyncProvider.Clock => beatSyncClock; + + private void setupBeatSyncProvider(PreviewTrack track, APIBeatmap beatmap) + { + beatSyncClock.Track = track; + + controlPoints.Add(0, new TimingControlPoint + { + BeatLength = beatmap.BPM > 0 ? 60_000 / beatmap.BPM : TimingControlPoint.DEFAULT_BEAT_LENGTH + }); + } + + private class PreviewTrackClock : IClock + { + public PreviewTrack? Track { get; set; } + + public double CurrentTime => Track?.CurrentTime ?? 0; + public double Rate => 1; + public bool IsRunning => Track?.IsRunning ?? false; + } + + #endregion + + private partial class PulseContainer : BeatSyncedContainer + { + public const double EXPAND_DURATION = 200; + + public PulseContainer() + { + MinimumBeatLength = minimum_beat_length; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!IsBeatSyncedWithTrack) + return; + + double beatLength = TimeUntilNextBeat; + + this.ScaleTo(1.02f, EXPAND_DURATION, Easing.In) + .Then() + .ScaleTo(1f, beatLength - EXPAND_DURATION, new CubicBezierEasingFunction(easeIn: 0.1f, easeOut: 1f)); + } + } + + private partial class RippleVisualization : BeatSyncedContainer + { + [Resolved] + private SongPreviewParticleContainer? particleContainer { get; set; } + + public required IBindable TrackRunning { get; init; } + + private readonly Color4 accentColour; + private readonly Container rippleContainer; + + public RippleVisualization(Color4 accentColour) + { + this.accentColour = accentColour; + + MinimumBeatLength = minimum_beat_length; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS + 1.5f, + Blending = BlendingParameters.Additive, + BorderThickness = 2f, + BorderColour = this.accentColour.Opacity(0.5f), + EdgeEffect = new EdgeEffectParameters + { + Colour = this.accentColour.Opacity(0.1f), + Type = EdgeEffectType.Glow, + Radius = 25f, + Hollow = true, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + EdgeSmoothness = new Vector2(3), + }, + }, + rippleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TrackRunning.BindValueChanged(e => + { + if (e.NewValue) + { + rippleContainer.Clear(); + this.FadeIn(100); + } + else + { + this.FadeOut(200); + } + }, true); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!IsBeatSyncedWithTrack) + return; + + var ripple = new Container + { + Size = DrawSize, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = CORNER_RADIUS, + BorderThickness = 2, + BorderColour = accentColour, + Blending = BlendingParameters.Additive, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + Alpha = 0, + }; + + rippleContainer.Add(ripple); + + const float expansion = 20; + + // The animation here is delayed to be in sync with the pulse-container's expansion animation. + // Since the pulse container expands with ease-out, the animation starts a tiny bit + // earlier, so it looks like it's maintaining the momentum of the pulse container's expansion + using (BeginDelayedSequence(PulseContainer.EXPAND_DURATION - 50)) + { + ripple.FadeIn(200) + .Then() + .FadeOut(1000); + + ripple.ResizeTo(DrawSize + new Vector2(expansion), 1000, Easing.OutQuart) + .TransformTo(nameof(CornerRadius), CORNER_RADIUS + expansion / 2, 1000, Easing.OutQuart) + .TransformTo(nameof(BorderThickness), 0.5f, 1000, Easing.In) + .Expire(); + + Schedule(() => particleContainer?.AddParticles(this, accentColour)); + } + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs new file mode 100644 index 0000000000..8ece23e4d3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs @@ -0,0 +1,241 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + [Cached] + public partial class RankedPlayCard : CompositeDrawable + { + public static readonly Vector2 SIZE = new Vector2(120, 200); + + public static readonly float CORNER_RADIUS = 6; + + public readonly RankedPlayCardWithPlaylistItem Item; + + private readonly IBindable playlistItem; + + public readonly Bindable SongPreviewEnabled = new BindableBool(true); + + private readonly Container content; + private readonly Container cardContent; + private readonly Container shadow; + private readonly SelectionOutline selectionOutline; + private readonly SongPreviewContainer songPreviewContainer; + + public bool ShowSelectionOutline + { + set => selectionOutline.FadeTo(value ? 1 : 0, 50); + } + + public float Elevation; + + public bool PreviewTrackLoaded => songPreviewContainer.TrackLoaded; + public bool PreviewTrackRunning => songPreviewContainer.IsRunning; + + private Sample? cardFlipSample; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + public RankedPlayCard(RankedPlayCardWithPlaylistItem item) + { + Item = item; + + Size = SIZE; + + playlistItem = item.PlaylistItem.GetBoundCopy(); + + InternalChild = songPreviewContainer = new SongPreviewContainer + { + Enabled = { BindTarget = SongPreviewEnabled }, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + shadow = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 5, + Colour = Color4.Black.Opacity(0.1f), + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + cardContent = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new RankedPlayCardBackSide() + }, + selectionOutline = new SelectionOutline + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + } + ] + } + ] + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + cardFlipSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-flip-1"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlistItem.BindValueChanged(e => onPlaylistItemChanged(e.NewValue)); + if (playlistItem.Value != null) + loadCardContent(playlistItem.Value, false); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + shadow.Scale = content.Scale; + shadow.Size = new Vector2(1 - Elevation * 0.25f); + shadow.Position = new Vector2(-25, 20) * Elevation; + } + + #region beatmap fetching logic & card flip + + private readonly TaskCompletionSource cardRevealed = new TaskCompletionSource(); + + public Task CardRevealed => cardRevealed.Task; + + private void onPlaylistItemChanged(MultiplayerPlaylistItem? playlistItem) + { + if (playlistItem == null) + { + SetContent(new RankedPlayCardBackSide(), true); + return; + } + + loadCardContent(playlistItem, true); + } + + private void loadCardContent(MultiplayerPlaylistItem playlistItem, bool flip) => Task.Run(async () => + { + var beatmap = await beatmapLookupCache.GetBeatmapAsync(playlistItem.BeatmapID).ConfigureAwait(false); + + cardRevealed.TrySetResult(); + + if (beatmap == null) + { + Logger.Log($"Failed to load beatmap {playlistItem.BeatmapID} for playlistItem {playlistItem.ID}.", level: LogLevel.Error); + return; + } + + Schedule(() => + { + SetContent(new RankedPlayCardContent(beatmap), flip); + songPreviewContainer.LoadPreview(beatmap); + }); + }); + + public void SetContent(Drawable newContent, bool flip) + { + if (!flip) + { + cardContent.Child = newContent; + return; + } + + content.ScaleTo(new Vector2(0, 1), 100, Easing.In) + .Then() + .Schedule(() => cardContent.Child = newContent) + .ScaleTo(new Vector2(1), 300, Easing.OutElasticQuarter); + + SamplePlaybackHelper.PlayWithRandomPitch(cardFlipSample); + } + + #endregion + + public void PopOutAndExpire() + { + content.ScaleTo(0, 500, Easing.In); + + this.FadeOut(500) + .Expire(); + } + + private partial class SelectionOutline : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + const float border_width = 4; + + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + // anti-aliasing would create a gap between the border & card here if we used border_width directly + Padding = new MarginPadding(-(border_width - 1)), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS + border_width, + BorderThickness = border_width, + BorderColour = Color4Extensions.FromHex("72D5FF"), + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 30, + Colour = Color4Extensions.FromHex("72D5FF").Opacity(0.2f), + Hollow = true, + Roundness = 10 + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs new file mode 100644 index 0000000000..be8ab9ab52 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardBackSide : CompositeDrawable + { + public RankedPlayCardBackSide() + { + Size = RankedPlayCard.SIZE; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = RankedPlayCard.CORNER_RADIUS; + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1, + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs new file mode 100644 index 0000000000..70f8d312fd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class AttributeListing(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + var rulesetInfo = rulesets.GetRuleset(beatmap.RulesetID); + Debug.Assert(rulesetInfo != null); + var ruleset = rulesetInfo.CreateInstance(); + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(7), + Children = + [ + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = + [ + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(4), + Children = + [ + new OsuSpriteText + { + Text = "Length", + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = beatmap.HitLength.ToFormattedDuration(), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + ] + }, + + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Spacing = new Vector2(4), + Children = + [ + new OsuSpriteText + { + Text = "BPM", + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = ((int)beatmap.BPM).ToString(), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + ] + }, + ] + }, + ..ruleset.GetBeatmapAttributesForDisplay(beatmap, []) + .Select(attribute => new AttributeRow(attribute)) + ] + }; + } + } + + private partial class AttributeRow(RulesetBeatmapAttribute attribute) : CompositeDrawable + { + private float normalizedValue => float.Clamp(attribute.AdjustedValue / attribute.MaxValue, 0, 1); + + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = + [ + new OsuSpriteText + { + Text = attribute.Label, + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + RelativePositionAxes = Axes.X, + Text = attribute.AdjustedValue.ToStandardFormattedString(maxDecimalDigits: 1), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + UseFullGlyphHeight = false, + X = 0.65f, + Padding = new MarginPadding { Right = 2 }, + Colour = colours.OnBackground, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.X, + Width = 0.35f, + Height = 2, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Masking = true, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.BackgroundLightest, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Width = normalizedValue, + Masking = true, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.PrimaryWithContrastToBackground, + }, + ] + } + ] + } + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs new file mode 100644 index 0000000000..f25e5a00b6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs @@ -0,0 +1,77 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + public class CardColours(APIBeatmap beatmap, OsuColour colour) + { + private static readonly Color4 base_background = Color4Extensions.FromHex("#222228"); + + public readonly Color4 Primary = colour.ForStarDifficulty(beatmap.StarRating); + + public Color4 OnPrimary => + beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF + ? colour.Orange1 + : getColour(1f, 0.15f); + + public Colour4 Background => mix(base_background, getColour(0.05f, 0.15f), 0.5f); + + public Colour4 BackgroundLighter => mix(base_background, getColour(0.1f, 0.2f), 0.5f); + + public Colour4 BackgroundLightest => mix(base_background, getColour(0.2f, 0.23f), 0.5f); + + public Color4 OnBackground => getColour(1f, 0.9f, isAccent: true); + + public Color4 Border => beatmap.StarRating > 8.0 ? Color4Extensions.FromHex("34044f") : Primary; + + public Colour4 PrimaryWithContrastToBackground => + beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? OnPrimary : Primary; + + private Color4 getColour(float saturation, float lightness, bool isAccent = false) + { + float hue = Primary.ToHSV().h / 360f; + + // at higher star ratings primary colour can become pure black. in that case we want to just use a very desaturated purple as base + if (beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF) + { + hue = isAccent ? 0.15f : 0.77f; + saturation *= 0.5f; + } + + // colours should generally shift slightly towards blue as they get darker + float shadowHue = 0.66f; + float colourShift = (1 - lightness) * 0.5f; + + // except yellow. yellow just *has* to look bad when you do that with it. it gets to fade to red + if (Math.Abs(hue - 0.16f) < 0.1f) + { + shadowHue = 0; + colourShift = float.Pow(colourShift, 0.25f); + } + + return mix( + Color4.FromHsl(new Vector4(hue, saturation, lightness, 1)), + Color4.FromHsl(new Vector4(shadowHue, saturation, lightness, 1)), + colourShift + ); + } + } + + private static Color4 mix(Color4 lhs, Color4 rhs, float alpha) => new Color4( + r: float.Lerp(lhs.R, rhs.R, alpha), + g: float.Lerp(lhs.G, rhs.G, alpha), + b: float.Lerp(lhs.B, rhs.B, alpha), + a: float.Lerp(lhs.A, rhs.A, alpha) + ); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs new file mode 100644 index 0000000000..c3e0d36412 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class CardCover(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + BufferedContainer coverContainer; + + InternalChildren = + [ + coverContainer = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + GrayscaleStrength = 0.25f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(colours.Background.Opacity(0.2f), colours.Background.Opacity(0.65f)) + } + ]; + + var cover = new OnlineBeatmapSetCover(beatmap.BeatmapSet) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + EdgeSmoothness = new Vector2(2), + }; + + LoadComponentAsync(cover, _ => + { + coverContainer.Add(cover); + cover.FadeInFromZero(200); + }); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs new file mode 100644 index 0000000000..2b935acca6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs @@ -0,0 +1,203 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class CardMetadata(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + InternalChildren = + [ + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = + [ + new StarRatingBadge(beatmap) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 4 }, + }, + ] + }, + new LinkFlowContainer(static s => s.ShadowOffset = new Vector2(0, 0.15f)) + { + Name = "Beatmap Metadata", + RelativeSizeAxes = Axes.Both, + TextAnchor = Anchor.BottomLeft, + Padding = new MarginPadding(5) { Bottom = 10 }, + ParagraphSpacing = 0.2f, + }.With(d => + { + d.AddText(new RomanisableString(beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title), static s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); + + d.NewLine(); + d.AddText(new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), static s => s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold)); + + d.NewParagraph(); + d.AddText("mapped by ", static s => s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold)); + d.AddText(beatmap.Metadata.Author.Username, s => + { + s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold); + s.Colour = colours.OnBackground; + }); + }), + ]; + } + } + + private partial class StarRatingBadge(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + AutoSizeAxes = Axes.Y; + Width = RankedPlayCard.SIZE.X - 20; + + Masking = true; + CornerRadius = 3; + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Primary, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 3, Vertical = 1 }, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + ], + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Content = new Drawable[][] + { + [ + new StarsDisplay(beatmap.StarRating) + { + StarSize = 6, + Colour = colours.OnPrimary, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TruncatingSpriteText + { + Text = FormattableString.Invariant($"{beatmap.StarRating:F2}"), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Bold), + Colour = colours.OnPrimary, + }, + ] + } + } + ]; + } + } + + private partial class StarsDisplay(double starRating) : CompositeDrawable + { + public required float StarSize { get; init; } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + FillFlowContainer flow; + + InternalChild = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(1), + }; + + int numStars = (int)starRating - 1; + + for (int i = 0; i <= numStars; i++) + { + flow.Add(new SpriteIcon + { + Size = new Vector2(StarSize), + Icon = FontAwesome.Solid.Star, + }); + } + + float lastStarWidth = (int)((starRating % 1) * 4) / 4f; + + if (lastStarWidth > 0) + { + flow.Add(new Container + { + Size = new Vector2(StarSize * lastStarWidth, StarSize), + Masking = true, + Child = new SpriteIcon + { + Icon = FontAwesome.Solid.Star, + Size = new Vector2(StarSize), + } + }); + } + } + } + + private partial class DifficultyNameBadge(APIBeatmap beatmap) : CompositeDrawable + { + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + Masking = true; + CornerRadius = 3; + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.BackgroundLighter, + }, + new TruncatingSpriteText + { + MaxWidth = 100f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.DifficultyName, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Colour = colours.OnBackground, + Padding = new MarginPadding { Vertical = 1 }, + } + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs new file mode 100644 index 0000000000..e75af5029c --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent : CompositeDrawable, IHasContextMenu + { + public readonly APIBeatmap Beatmap; + + private CardColours colours = null!; + + [Resolved] + private CardDetailsOverlayContainer? cardDetailsOverlay { get; set; } + + public RankedPlayCardContent(APIBeatmap beatmap) + { + Size = RankedPlayCard.SIZE; + + Beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = RankedPlayCard.CORNER_RADIUS, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background, + }, + new Container + { + Name = "Top Area", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Children = + [ + new CardCover(Beatmap) + { + RelativeSizeAxes = Axes.Both, + }, + new CardMetadata(Beatmap) + { + RelativeSizeAxes = Axes.Both, + }, + new DifficultyNameBadge(Beatmap) + { + Width = 100, + AutoSizeAxes = Axes.Y, + + // this container partially overlaps with the bottom area + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + } + ], + }, + new Container + { + Name = "Bottom Area", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = RankedPlayCard.SIZE.X + 6 }, + Children = + [ + new AttributeListing(Beatmap) + { + RelativeSizeAxes = Axes.Both, + } + ] + }, + ] + }, + new CardBorder() + ]; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.CacheAs(colours = new CardColours(Beatmap, dependencies.Get())); + + return dependencies; + } + + public override bool HandlePositionalInput => true; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (IsHovered) + cardDetailsOverlay?.ShowCardDetails(this, Beatmap); + } + + private partial class CardBorder : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = RankedPlayCard.CORNER_RADIUS; + BorderThickness = 1.5f; + BorderColour = ColourInfo.GradientVertical(colours.Border.Opacity(0.5f), colours.Border.Opacity(0)); + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + EdgeSmoothness = new Vector2(3), + }; + } + } + + [Resolved] + private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + + public MenuItem[] ContextMenuItems => + [ + new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, () => beatmapSetOverlay?.ShowBeatmapSet(Beatmap.BeatmapSet)) + ]; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs new file mode 100644 index 0000000000..bb7526f8b8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public static class RankedPlayCardExtensions + { + /// + /// Adjusts the transforms of a drawable relative to a parent drawable to match the given drawQuad. + /// + /// the target drawable. + /// screen space drawQuad to fit the drawable to. + /// drawable to calculate the transforms in relation to. + public static T MatchScreenSpaceDrawQuad(this T target, Quad drawQuad, CompositeDrawable parent) where T : Drawable + { + drawQuad = parent.ToLocalSpace(drawQuad); + + var originPosition = target.RelativeOriginPosition; + + // child may not have been made alive yet by the parent so anchor is calculated manually + var anchorPosition = parent.ChildSize * target.RelativeAnchorPosition; + + var positionWithOrigin = Vector2.Lerp( + Vector2.Lerp(drawQuad.TopLeft, drawQuad.TopRight, originPosition.X), + Vector2.Lerp(drawQuad.BottomLeft, drawQuad.BottomRight, originPosition.X), + originPosition.Y + ); + + target.Position = positionWithOrigin - anchorPosition; + + target.Rotation = MathHelper.RadiansToDegrees(new Line(drawQuad.TopLeft, drawQuad.TopRight).Theta); + + target.Scale = new Vector2(Vector2.Distance(drawQuad.TopLeft, drawQuad.TopRight) / target.DrawWidth); + + return target; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs new file mode 100644 index 0000000000..89319d9fd0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs @@ -0,0 +1,129 @@ +// 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 System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class SongPreviewParticleContainer : CompositeDrawable + { + public SongPreviewParticleContainer() + { + RelativeSizeAxes = Axes.Both; + } + + private Vector2 lastPosition; + + private Texture[] particleTextures = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + const int texture_count = 3; + + particleTextures = new Texture[texture_count]; + + for (int i = 0; i < texture_count; i++) + { + particleTextures[i] = textures.Get($"Online/RankedPlay/note-particle-{i}"); + Debug.Assert(particleTextures[i] != null); + } + } + + public void AddParticles(Drawable source, Color4 seedColour) + { + var drawQuad = ToLocalSpace(source.ScreenSpaceDrawQuad); + + var position = sampleRandomPosition(drawQuad); + + for (int i = 0; i < 10; i++) + { + if (Vector2.Distance(position, lastPosition) > 100) + break; + + position = sampleRandomPosition(drawQuad); + } + + lastPosition = position; + + var texture = particleTextures[RNG.Next(particleTextures.Length)]; + + var particle = new Particle(texture) + { + Position = position, + Rotation = RNG.NextSingle(-3, 3), + Colour = seedColour, + Blending = BlendingParameters.Additive, + }; + + AddInternal(particle); + + particle.ScaleTo(0) + .ScaleTo(RNG.NextSingle(0.75f, 1f), 1000, Easing.OutElasticHalf) + .Then() + .FadeOut(1800, Easing.OutCubic) + .Expire(); + } + + private static Vector2 sampleRandomPosition(Quad quad) + { + static float remap(float value, float fromLower, float fromHigher, float toLower, float toHigher) => + (value - fromLower) / (fromHigher - fromLower) * (toHigher - toLower) + toLower; + + static float randomValue() + { + float x = RNG.NextSingle(); + // using quadratic rational smoothstep to increase the likelihood that particles spawn at the edge of the card + float smoothStep = x * x / (2f * x * x - 2f * x + 1f); + + if (smoothStep < 0.5f) + return remap(smoothStep, 0, 0.5f, -0.05f, 0.15f); + else + return remap(smoothStep, 0.5f, 1f, 0.85f, 1.05f); + } + + var top = Vector2.Lerp(quad.TopLeft, quad.TopRight, randomValue()); + var bottom = Vector2.Lerp(quad.BottomLeft, quad.BottomRight, randomValue()); + + return Vector2.Lerp(top, bottom, randomValue()); + } + + private partial class Particle : Sprite + { + public Particle(Texture texture) + { + Size = new Vector2(40); + Texture = texture; + Origin = Anchor.Centre; + } + + private float initialX; + private readonly float seed = RNG.NextSingle() * MathF.PI * 2; + + protected override void LoadComplete() + { + base.LoadComplete(); + + initialX = X; + } + + protected override void Update() + { + base.Update(); + + X = initialX + (float)Math.Cos(Time.Current * 0.002 + seed) * 5; + Y -= (float)(Time.Elapsed * 0.04f); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs new file mode 100644 index 0000000000..af9985ac27 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + /// + /// Container that arranges a collection of s horizontally. + /// Layout is not automatic and has to be triggered by calling + /// + /// + /// Drawables are expected to be added to this container with an Anchor/Origin of . + /// + public partial class CardFlow : Container + { + public float Spacing = 20; + + /// + /// Moves all cards into a horizontal arrangement centered within the container's bounds. + /// + /// delay to be added to the movement of each subsequent card + /// duration of the movement + /// easing of the movement + public void LayoutCards(double stagger = 0, double duration = 400, Easing easing = Easing.OutExpo) + { + // makes sure that all facades had a chance to initialize their transforms based on the provided drawQuad + CheckChildrenLife(); + + float totalWidth = Children.Sum(c => c.LayoutSize.X + Spacing) - Spacing; + + float x = -totalWidth / 2; + + double delay = 0; + + foreach (var card in Children) + { + card.Delay(delay) + .MoveTo(new Vector2(x + card.LayoutSize.X * 0.5f, 0), duration, easing) + .RotateTo(0, duration, easing) + .ScaleTo(1, duration, easing); + + x += card.LayoutSize.X + Spacing; + + delay += stagger; + } + } + + /// + /// + /// + /// + /// + /// + /// + public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false)] out RankedPlayCard card, out Quad screenSpaceDrawQuad) + { + card = Children.FirstOrDefault(it => it.Item.Equals(item)); + + if (card == null) + { + screenSpaceDrawQuad = default; + return false; + } + + screenSpaceDrawQuad = card.ScreenSpaceDrawQuad; + + Remove(card, false); + + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs new file mode 100644 index 0000000000..c2a4825de4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + [Cached] + public partial class RankedPlayCornerPiece : VisibilityContainer + { + private readonly BufferedContainer background; + private readonly Container bottomLayer; + private readonly Container topLayer; + + protected override Container Content { get; } + + public RankedPlayCornerPiece(RankedPlayColourScheme colourScheme, Anchor anchor) + { + Size = new Vector2(345, 100); + + Anchor = Origin = anchor; + + InternalChildren = + [ + background = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2( + (anchor & Anchor.x0) != 0 ? 1 : -1, + (anchor & Anchor.y0) != 0 ? -1 : 1 + ), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Rotation = -2, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Shear = new Vector2(-0.5f, 0), + Padding = new MarginPadding + { + Left = -60, + Bottom = -30, + Top = 20, + Right = 15, + }, + Children = + [ + bottomLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.PrimaryDarkest, + Alpha = 0.2f, + // This is a hack to work around alpha-blending issues when drawing on top of a transparent background without premultiplied alpha + // This method requires that this Drawable is not drawn on top of anything else + Blending = BlendingParameters.Mixture with + { + Destination = BlendingType.Zero, + DestinationAlpha = BlendingType.Zero, + Source = BlendingType.One, + SourceAlpha = BlendingType.One, + } + }, + }, + topLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 15, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colourScheme.Primary, colourScheme.PrimaryDarker.Opacity(0.35f)), + Alpha = 0.75f + }, + }, + } + ] + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = anchor, + Origin = anchor, + Margin = new MarginPadding(18), + Child = Content = new Container + { + Anchor = (anchor & Anchor.x0) != 0 ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = (anchor & Anchor.x0) != 0 ? Anchor.CentreLeft : Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + } + } + ]; + } + + public void OnHealthChanged(int health) + { + background.GrayscaleTo(health <= 0f ? 0.75f : 0, 300); + } + + protected override void Update() + { + base.Update(); + + Width = WidthFor(Parent!.ChildSize.X); + } + + public static float WidthFor(float parentWidth) => float.Clamp(parentWidth * 0.25f, 250, 335); + + protected override void PopIn() + { + this.FadeIn(300); + + Content.Delay(150) + .MoveToX(0, 400, Easing.OutExpo) + .ScaleTo(1f, 400, Easing.OutExpo) + .FadeIn(); + + background.MoveToY(0, 400, Easing.OutExpo); + + bottomLayer.RotateTo(0, 400, Easing.OutQuart); + topLayer.RotateTo(0, 400, Easing.OutQuart); + } + + protected override void PopOut() + { + this.FadeOut(300); + + background.MoveToY((Anchor & Anchor.y0) != 0 ? -60 : 60, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + Content.MoveToX((Anchor & Anchor.x0) != 0 ? -200 : 200, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.5)) + .ScaleTo(0.5f, 400, Easing.OutCubic) + .FadeOut(200); + + bottomLayer.RotateTo(-25, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + topLayer.RotateTo(25, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs new file mode 100644 index 0000000000..d84df287af --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs @@ -0,0 +1,234 @@ +// 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 System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayScoreCounter : CompositeDrawable + { + private readonly FillFlowContainer digitFlow; + private readonly CounterDigit[] digits; + + public required FontUsage Font { get; init; } + + private long value; + + public long Value + { + get => value; + set + { + this.value = value; + if (!IsLoaded) + return; + + updateDigits(); + } + } + + public TransformSequence TransformValueTo(long value, double duration = 0, Easing easing = Easing.None) => this.TransformTo(nameof(Value), value, duration, easing); + + public RankedPlayScoreCounter(int numDigits = 6) + { + digits = new CounterDigit[numDigits]; + + AutoSizeAxes = Axes.Both; + + InternalChildren = + [ + digitFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + } + ]; + } + + public Vector2 Spacing + { + get => digitFlow.Spacing; + set => digitFlow.Spacing = value; + } + + [BackgroundDependencyLoader] + private void load() + { + string templateString = Math.Pow(10, digits.Length - 1).ToString("N0"); + + for (int i = 0, digitIndex = 0; i < templateString.Length; i++) + { + if (char.IsDigit(templateString[i])) + { + digitFlow.Add(digits[digitIndex++] = new CounterDigit + { + Font = Font.With(fixedWidth: true), + }); + } + else + { + digitFlow.Add(new OsuSpriteText + { + Text = templateString[i].ToString(), + Font = Font, + Shadow = false, + }); + } + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateDigits(false); + } + + public void SetValueInstantly(long value) + { + ClearTransforms(true); + this.value = value; + updateDigits(false); + } + + private void updateDigits(bool animated = true) + { + long current = value; + + for (int i = digits.Length - 1; i >= 0; i--) + { + digits[i].Offset = current; + + if (!animated) + digits[i].CompleteAnimations(); + + current /= 10; + } + } + + private partial class CounterDigit : CompositeDrawable + { + private readonly DoubleSpring spring = new DoubleSpring + { + NaturalFrequency = 2.5f, + Damping = 0.8f, + Response = 1f + }; + + public double Offset { get; set; } + + private OsuSpriteText upperDigit = null!; + private OsuSpriteText lowerDigit = null!; + + private BufferedContainer blurContainer = null!; + + public required FontUsage Font { get; init; } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(Font.FixedWidth); + + InternalChild = blurContainer = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Height = 3f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BackgroundColour = Colour4.White.Opacity(0), + Children = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Height = 1f / 3f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + upperDigit = new OsuSpriteText + { + Text = "9", + Font = Font, + RelativePositionAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = false, + }, + lowerDigit = new OsuSpriteText + { + Text = "0", + Font = Font, + RelativePositionAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = false, + } + ] + } + ] + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Size = lowerDigit.DrawSize; + } + + protected override void Update() + { + base.Update(); + + spring.Damping = spring.Velocity > 30 ? 1f : 0.8f; + + spring.Update(Time.Elapsed, Offset); + + updateState(); + } + + private void updateState() + { + int digit = (int)spring.Current % 10; + if (digit < 0) digit += 10; + + lowerDigit.Text = digit.ToString(); + upperDigit.Text = ((digit + 1) % 10).ToString(); + + float y = (float)(spring.Current % 1); + + if (y < 0) + y = 0; + + upperDigit.Y = (y - 1) * 0.65f; + lowerDigit.Y = y * 0.65f; + + lowerDigit.Alpha = MathF.Pow(1 - y, 2); + upperDigit.Alpha = MathF.Pow(y, 2); + + upperDigit.Scale = new Vector2(float.Lerp(0.5f, 1f, MathF.Sqrt(0.5f + y * 0.5f))); + lowerDigit.Scale = new Vector2(float.Lerp(0.5f, 1f, MathF.Sqrt(1 - y * 0.5f))); + + blurContainer.BlurSigma = new Vector2(0, float.Clamp((float)Math.Abs(spring.Velocity * 0.1f) - 5, 0, 10)); + } + + public void CompleteAnimations() + { + spring.Current = Offset; + spring.PreviousTarget = Offset; + spring.Velocity = 0; + + updateState(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs new file mode 100644 index 0000000000..d0fb4f9332 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs @@ -0,0 +1,240 @@ +// 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.Audio; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayStageDisplay : CompositeDrawable + { + public required LocalisableString Heading { get; init; } + + public required LocalisableString Caption { get; init; } + + public Color4? CaptionColour { get; init; } + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly RankedPlayColourScheme colourScheme; + + private Drawable headingTextBackground = null!; + private OsuSpriteText headingText = null!; + private Drawable progressBar = null!; + private OsuSpriteText progressText = null!; + + private DateTimeOffset countdownStartTime; + private DateTimeOffset countdownEndTime; + + public RankedPlayStageDisplay(RankedPlayColourScheme colourScheme) + { + this.colourScheme = colourScheme; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + const float phase_text_background_height = 55; + Vector2 progressBarSize = new Vector2(300, 25); + MarginPadding progressBarMargin = new MarginPadding + { + Left = 40, + Top = phase_text_background_height - progressBarSize.Y / 2 + }; + + InternalChildren = new Drawable[] + { + new BufferedContainer + { + AutoSizeAxes = Axes.Both, + BackgroundColour = colourScheme.Surface.Opacity(0), + Alpha = 0.7f, + Children = new[] + { + headingTextBackground = new Container + { + Height = phase_text_background_height, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface.Darken(0.1f), + Alpha = 0.8f + } + }, + new Container + { + Size = progressBarSize, + Margin = progressBarMargin, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 3, + BorderThickness = 1f, + BorderColour = ColourInfo.GradientVertical(colourScheme.Surface, colourScheme.SurfaceBorder), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + } + }, + } + }, + headingText = new OsuSpriteText + { + Margin = new MarginPadding + { + Top = 5, + Left = 20, + }, + Text = Heading, + Font = OsuFont.TorusAlternate.With(size: 34), + Shadow = false, + }, + new Container + { + Size = progressBarSize, + Shear = OsuGame.SHEAR, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, + Margin = progressBarMargin, + Children = + [ + progressBar = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 2, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Colour = ColourInfo.GradientHorizontal(colourScheme.PrimaryDarker, colourScheme.Primary) + }, + new TrianglesV2 + { + Width = progressBarSize.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + SpawnRatio = 0.5f, + ScaleAdjust = 0.75f, + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + Colour = ColourInfo.GradientHorizontal(Color4.Transparent, Color4.White) + }, + ], + }, + progressText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = -OsuGame.SHEAR, + Margin = new MarginPadding + { + Left = 10 + }, + UseFullGlyphHeight = false, + Text = "00:27:123", + Font = OsuFont.TorusAlternate.With(size: 16, fixedWidth: true, weight: FontWeight.SemiBold) + } + ] + }, + new OsuSpriteText + { + Margin = new MarginPadding + { + Top = 80, + Left = 20 + }, + Colour = CaptionColour ?? colourScheme.Primary, + Text = Caption, + Font = OsuFont.TorusAlternate.With(size: 24, weight: FontWeight.SemiBold) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.CountdownStarted += onCountdownStarted; + client.CountdownStopped += onCountdownStopped; + + if (client.Room != null) + { + foreach (var countdown in client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + headingTextBackground.Width = headingText.DrawWidth + 80; + + TimeSpan duration = countdownEndTime - countdownStartTime; + TimeSpan remaining = countdownEndTime - DateTimeOffset.Now; + + if (duration > TimeSpan.Zero) + progressBar.Width = (float)Math.Clamp(remaining / duration, 0, 1); + + int minutes = (int)Math.Max(0, remaining.TotalMinutes); + int seconds = Math.Max(0, remaining.Seconds); + int ms = Math.Max(0, remaining.Milliseconds); + + progressText.Text = $"{minutes:00}:{seconds:00}.{ms:000}"; + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + countdownStartTime = DateTimeOffset.Now; + countdownEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + countdownEndTime = DateTimeOffset.Now; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.CountdownStarted -= onCountdownStarted; + client.CountdownStopped -= onCountdownStopped; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs new file mode 100644 index 0000000000..0195c47945 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs @@ -0,0 +1,398 @@ +// 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.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayUserDisplay : CompositeDrawable + { + public readonly BindableInt Health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + [Resolved] + private UserLookupCache users { get; set; } = null!; + + private readonly int userId; + private readonly Anchor contentAnchor; + private readonly RankedPlayColourScheme colourScheme; + + private BufferedContainer grayScaleContainer = null!; + + [Resolved] + private RankedPlayCornerPiece? cornerPiece { get; set; } + + public RankedPlayUserDisplay(int userId, Anchor contentAnchor, RankedPlayColourScheme colourScheme) + { + this.userId = userId; + this.contentAnchor = contentAnchor; + this.colourScheme = colourScheme; + } + + [BackgroundDependencyLoader] + private void load() + { + APIUser user = users.GetUserAsync(userId).GetResultSafely()!; + + var shear = contentAnchor == Anchor.TopLeft || contentAnchor == Anchor.BottomRight + ? -OsuGame.SHEAR + : OsuGame.SHEAR; + + InternalChildren = + [ + new CircularContainer + { + Name = "Avatar", + Size = new Vector2(72), + Masking = true, + Anchor = contentAnchor, + Origin = contentAnchor, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + Alpha = 0.5f, + }, + grayScaleContainer = new BufferedContainer(cachedFrameBuffer: false, pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Child = new UpdateableAvatar(user) + { + RelativeSizeAxes = Axes.Both, + } + } + ] + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = (contentAnchor & Anchor.x0) != 0 ? new MarginPadding { Left = 72 } : new MarginPadding { Right = 72 }, + Direction = FillDirection.Vertical, + Children = + [ + HealthDisplay = new HealthBar(colourScheme, (contentAnchor & Anchor.x0) != 0, shear) + { + Health = { BindTarget = Health }, + RelativeSizeAxes = Axes.X, + Height = 22, + Anchor = contentAnchor, + Origin = contentAnchor, + }, + new OsuSpriteText + { + Name = "Username", + Text = user.Username, + Anchor = contentAnchor, + Origin = contentAnchor, + Padding = new MarginPadding { Horizontal = 4, Vertical = 6 }, + Font = OsuFont.GetFont(size: 24, weight: FontWeight.SemiBold), + UseFullGlyphHeight = false, + }, + ] + } + ]; + } + + public HealthBar HealthDisplay { get; private set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Health.BindValueChanged(e => + { + grayScaleContainer.GrayscaleTo(e.NewValue <= 0 ? 1 : 0, 300); + cornerPiece?.OnHealthChanged(e.NewValue); + }); + } + + public partial class HealthBar : CompositeDrawable + { + private readonly bool leftToRight; + + public readonly BindableInt Health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + private readonly BindableInt healthTextValue = new BindableInt(); + + /// + /// relative health threshold below which the health bar starts flashing red + /// + public float HealthFlashThreshold { get; set; } = 0.3f; + + private readonly ColourInfo healthBarColour; + + private readonly Container healthBar; + private readonly Box healthBarBackground; + private readonly Container damageIndicator; + private readonly TrianglesV2 triangles; + private readonly SpriteIcon heartIcon; + private readonly OsuSpriteText healthText; + + /// + /// Impact position for damage animation + /// + public Vector2 ScreenSpaceImpactPosition + { + get + { + var rect = healthBar.ScreenSpaceDrawQuad.AABBFloat; + + return leftToRight ? new Vector2(rect.Right, rect.Centre.Y) : new Vector2(rect.Left, rect.Centre.Y); + } + } + + public HealthBar(RankedPlayColourScheme colourScheme, bool leftToRight, Vector2 shear) + { + this.leftToRight = leftToRight; + + Shear = shear; + + Anchor contentAnchor = leftToRight ? Anchor.CentreLeft : Anchor.CentreRight; + + BufferedContainer content; + + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 3, + BorderThickness = 1f, + BorderColour = ColourInfo.GradientVertical(colourScheme.Surface, colourScheme.SurfaceBorder), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + Alpha = 0.8f, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, // slightly different ratio to account for shear + Children = + [ + healthBar = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 2, + Anchor = contentAnchor, + Origin = contentAnchor, + Children = + [ + healthBarBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Colour = healthBarColour = leftToRight + ? ColourInfo.GradientHorizontal(colourScheme.PrimaryDarker, colourScheme.Primary) + : ColourInfo.GradientHorizontal(colourScheme.Primary, colourScheme.PrimaryDarker), + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Y, + Anchor = contentAnchor, + Origin = contentAnchor, + SpawnRatio = 0.5f, + ScaleAdjust = 0.75f, + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + Colour = leftToRight + ? ColourInfo.GradientHorizontal(Color4.Transparent, Color4.White) + : ColourInfo.GradientHorizontal(Color4.White, Color4.Transparent), + }, + ], + }, + ] + }, + content = new BufferedContainer(pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Shear = -shear, + BackgroundColour = Color4.White.Opacity(0), // workaround for non-premultiplied alpha blending of white content on transparent background + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(3), + Padding = new MarginPadding { Horizontal = 10 }, + Children = + [ + new Container + { + Size = new Vector2(10), + Anchor = contentAnchor, + Origin = contentAnchor, + Child = heartIcon = new SpriteIcon + { + Icon = FontAwesome.Solid.Heart, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + healthText = new OsuSpriteText + { + Text = "1,000,000", + Anchor = contentAnchor, + Origin = contentAnchor, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Medium, fixedWidth: true), + Spacing = new Vector2(-1, 0), + UseFullGlyphHeight = false, + Padding = new MarginPadding { Top = 1 }, + Shadow = false, + } + ] + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, // slightly different ratio to account for shear + Children = + [ + damageIndicator = new Container + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + Anchor = contentAnchor, + Origin = contentAnchor, + Masking = true, + CornerRadius = 2, + Alpha = 0, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 25, + Colour = Color4Extensions.FromHex("FF171B").Opacity(0.5f), + Roundness = 10, + Hollow = true, + }, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + }, + content.CreateView().With(d => + { + d.SynchronisedDrawQuad = true; + d.Colour = Color4.Red; + }) + ], + }, + ] + }, + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Health.BindValueChanged(onHealthChanged, true); + + healthTextValue.BindValueChanged(e => healthText.Text = FormattableString.Invariant($"{e.NewValue:N0}"), true); + + FinishTransforms(true); + + Scheduler.AddDelayed(flashHealth, 1000, true); + } + +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value + private float normalizedHealth; + private float normalizedHealthWithDamage; +#pragma warning restore CS0649 // Field is never assigned to, and will always have its default value + + private void onHealthChanged(ValueChangedEvent e) + { + this.TransformBindableTo(healthTextValue, e.NewValue, 500, Easing.OutExpo); + + bool isHealthDecrease = e.NewValue < e.OldValue; + + if (isHealthDecrease) + { + damageIndicator.FadeIn(50) + .Then(delay: 1100) + .FadeOut(200); + + healthBarBackground.FadeColour(Color4.Red, 100) + .Then() + .FadeColour(healthBarColour, 1000); + + this.TransformTo(nameof(normalizedHealthWithDamage), Health.NormalizedValue, 400, Easing.OutExpo) + .Then(500) + .TransformTo(nameof(normalizedHealth), Health.NormalizedValue, 800, Easing.OutExpo); + } + + else + { + this.TransformTo(nameof(normalizedHealthWithDamage), Health.NormalizedValue, 800, Easing.OutExpo) + .TransformTo(nameof(normalizedHealth), Health.NormalizedValue, 800, Easing.OutExpo); + } + } + + protected override void Update() + { + base.Update(); + + triangles.Width = DrawWidth; + healthBar.Width = normalizedHealth; + + damageIndicator.X = leftToRight ? normalizedHealthWithDamage : -normalizedHealthWithDamage; + damageIndicator.Width = float.Clamp(normalizedHealth - normalizedHealthWithDamage, 0, 1); + } + + private void flashHealth() + { + if (Health.NormalizedValue > HealthFlashThreshold) + return; + + var almostRed = Interpolation.ValueAt(0.75, healthBarColour, ColourInfo.SingleColour(Color4.Red), 0.0, 1.0); + + healthBarBackground.FadeColour(almostRed, 150) + .Then() + .FadeColour(healthBarColour, 800); + + heartIcon + .ScaleTo(0.8f, 150, Easing.Out) + .Then() + .ScaleTo(1f, 400, Easing.OutElasticHalf); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs new file mode 100644 index 0000000000..fbc86e5c97 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs @@ -0,0 +1,352 @@ +// 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 System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class DiscardScreen : RankedPlaySubScreen + { + // When the 'time running out' warning sample starts to play (in remaining seconds) + private const int warning_time_threshold = 10; + + public CardFlow CenterRow { get; private set; } = null!; + + private PlayerHandOfCards playerHand = null!; + private ShearedButton discardButton = null!; + private OsuTextFlowContainer explainer = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private Sample? cardAddSample; + private Sample? cardDiscardSample; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + private bool timeRunningOutWarningActive; + private Sample? timeRunningOutSample; + private SampleChannel? timeRunningOutSampleChannel; + + private DateTimeOffset stageEndTime; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new RankedPlayStageDisplay(RankedPlayColourScheme.Blue) + { + Heading = "Discard Phase", + Caption = "Replace cards from your hand", + CaptionColour = Color4.White, + Margin = new MarginPadding { Top = 60 }, + }, + discardButton = new ShearedButton + { + Name = "Discard Button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150, + Action = onDiscardButtonClicked, + Enabled = { Value = true }, + } + ]; + + CenterColumn.Children = + [ + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + SelectionMode = HandSelectionMode.Multiple, + }, + explainer = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 24)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + TextAnchor = Anchor.TopCentre, + Y = 250, + ParagraphSpacing = 1, + Alpha = 0, + }.With(d => + { + d.AddParagraph("These are your cards for this match!"); + d.AddParagraph("When it’s your turn, you can play a card to go head-to-head against your opponent!"); + }) + ]; + + cardAddSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-add-1"); + cardDiscardSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-discard-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}"); + + timeRunningOutSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-running-out"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.PlayerCardAdded += cardAdded; + matchInfo.PlayerCardRemoved += cardRemoved; + + playerHand.SelectionChanged += onSelectionChanged; + + Client.CountdownStarted += onCountdownStarted; + Client.CountdownStopped += onCountdownStopped; + + if (Client.Room != null) + { + foreach (var countdown in Client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + + onSelectionChanged(); + } + + protected override void Update() + { + base.Update(); + + TimeSpan remainingTime = stageEndTime - DateTimeOffset.Now; + + if (timeRunningOutWarningActive && remainingTime.TotalSeconds < warning_time_threshold) + { + timeRunningOutSampleChannel ??= timeRunningOutSample?.GetChannel(); + + if (timeRunningOutSampleChannel == null || timeRunningOutSampleChannel.Playing) + return; + + timeRunningOutSampleChannel.ManualFree = true; + timeRunningOutSampleChannel.Looping = true; + timeRunningOutSampleChannel.Play(); + } + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + var screenBottomCenter = new Vector2(DrawWidth / 2, DrawHeight); + int cardCount = 0; + + foreach (var card in matchInfo.PlayerCards) + { + playerHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(screenBottomCenter, playerHand); + }); + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, 50 * cardCount); + cardCount++; + } + + playerHand.UpdateLayout(stagger: 50); + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown stageCountdown) + return; + + stageEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + timeRunningOutWarningActive = stageCountdown.Stage == RankedPlayStage.CardDiscard; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + timeRunningOutSampleChannel?.Stop(); + + stageEndTime = DateTimeOffset.Now; + timeRunningOutWarningActive = false; + }); + + private void onSelectionChanged() + { + if (playerHand.Selection.Any()) + discardButton.Text = $"Replace {"card".ToQuantity(playerHand.Selection.Count())}"; + else + discardButton.Text = "Keep cards"; + } + + private void onDiscardButtonClicked() + { + discardButton.Hide(); + + Client.DiscardCards(playerHand.Selection.Select(it => it.Card).ToArray()).FireAndForget(); + playerHand.SelectionMode = HandSelectionMode.Disabled; + } + + private readonly List discardedCards = new List(); + + private void cardRemoved(RankedPlayCardWithPlaylistItem item) => discardedCards.Add(item); + + private void playDiscardAnimation() + { + const double stagger = 100; + double delay = 0; + + foreach (var item in discardedCards) + { + if (!playerHand.RemoveCard(item, out var card, out Quad drawQuad)) + return; + + card.Anchor = Anchor.Centre; + card.Origin = Anchor.Centre; + + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + + CenterRow.Add(card); + + using (BeginDelayedSequence(1000 + delay)) + { + card.PopOutAndExpire(); + } + + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + }, delay); + + delay += stagger; + } + + Scheduler.AddDelayed(() => + { + cardDiscardSample?.Play(); + }, 1000); + + discardedCards.Clear(); + CenterRow.LayoutCards(stagger: stagger); + } + + private double nextCardDrawTime; + private double earliestPresentationTime; + + private void cardAdded(RankedPlayCardWithPlaylistItem card) + { + if (discardedCards.Count > 0) + { + playDiscardAnimation(); + nextCardDrawTime = Math.Max(nextCardDrawTime, Time.Current + 2000); + } + + double delay = Math.Max(0, nextCardDrawTime - Time.Current); + nextCardDrawTime = Time.Current + delay + 100; + + earliestPresentationTime = Time.Current + 3500; + + Scheduler.AddDelayed(() => + { + playerHand.AddCard(card, d => + { + d.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth, DrawHeight * 0.5f), playerHand); + d.Rotation = -30; + }); + + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, delay); + } + + public void PresentRemainingCards() + { + discardButton.Hide(); + + double presentationTime = Math.Max(earliestPresentationTime, Time.Current); + Scheduler.AddDelayed(presentRemainingCards, presentationTime - Time.Current); + } + + private void presentRemainingCards() + { + int delay = 0; + + foreach (var item in matchInfo.PlayerCards) + { + if (playerHand.RemoveCard(item, out var card, out Quad drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + + CenterRow.Add(card); + + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + }, delay); + + delay += 50; + } + else + { + CenterRow.Add(new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + CenterRow.LayoutCards(stagger: 50, duration: 600); + + explainer + .Delay(100) + .MoveToOffset(new Vector2(0, 50)) + .MoveToOffset(new Vector2(0, -50), 600, Easing.OutExpo) + .FadeIn(250); + } + + protected override void Dispose(bool isDisposing) + { + timeRunningOutSampleChannel?.Stop(); + timeRunningOutSampleChannel?.Dispose(); + + matchInfo.PlayerCardAdded -= cardAdded; + matchInfo.PlayerCardRemoved -= cardRemoved; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs new file mode 100644 index 0000000000..ba0549e5fa --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs @@ -0,0 +1,207 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class EndedScreen : RankedPlaySubScreen + { + /// + /// Invoked when the user requests to exit this screen. + /// + public Action? ExitRequested { get; init; } + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private OsuSpriteText titleText = null!; + private Drawable titleSeparator = null!; + private OsuTextFlowContainer localRatingText = null!; + private OsuTextFlowContainer opponentRatingText = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + CenterColumn.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new[] + { + titleText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "VICTORY", + Font = OsuFont.Torus.With(size: 100, weight: FontWeight.SemiBold), + UseFullGlyphHeight = false, + Colour = colours.Green1, + }, + titleSeparator = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = colours.Green1 + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 8, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + localRatingText = new OsuTextFlowContainer(s => s.Font = OsuFont.Style.Heading1) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 8, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + opponentRatingText = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ShearedButton + { + Width = 100, + Text = "Quit", + Action = () => ExitRequested?.Invoke(false), + DarkerColour = colours.Red3, + LighterColour = colours.Red4, + }, + new ShearedButton + { + Width = 200, + Text = "Play Again", + Action = () => ExitRequested?.Invoke(true), + DarkerColour = colours.Green3, + LighterColour = colours.Green4, + }, + } + } + } + }; + + RankedPlayUserInfo localUser = matchInfo.RoomState.Users[Client.LocalUser!.UserID]; + RankedPlayUserInfo otherUser = matchInfo.RoomState.Users.Values.Single(u => u != localUser); + + if (matchInfo.RoomState.WinningUserId == null) + { + titleText.Text = "DRAW"; + titleText.Colour = titleSeparator.Colour = colours.Orange1; + } + else if (matchInfo.RoomState.WinningUserId == Client.LocalUser!.UserID) + { + titleText.Text = "VICTORY"; + titleText.Colour = titleSeparator.Colour = colours.Green1; + } + else + { + titleText.Text = "DEFEAT"; + titleText.Colour = titleSeparator.Colour = colours.Red1; + } + + localRatingText.AddText("Your Rating: ", s => s.Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular)); + localRatingText.AddText(localUser.RatingAfter.ToString("N0"), s => s.Font = OsuFont.Style.Heading1); + localRatingText.AddText($" ({localUser.RatingAfter - localUser.Rating:+0;-0;+0})", s => + { + s.Font = OsuFont.Style.Caption1; + s.Colour = localUser.RatingAfter >= localUser.Rating ? colours.GreenDark : colours.RedDark; + }); + + opponentRatingText.AddText("Opponent Rating: ", s => s.Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular)); + opponentRatingText.AddText(otherUser.RatingAfter.ToString("N0"), s => s.Font = OsuFont.Style.Heading1); + opponentRatingText.AddText($" ({otherUser.RatingAfter - otherUser.Rating:+0;-0;+0})", s => + { + s.Font = OsuFont.Style.Caption1; + s.Colour = otherUser.RatingAfter >= otherUser.Rating ? colours.GreenDark : colours.RedDark; + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs new file mode 100644 index 0000000000..8a558b24e3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayScreen : RankedPlaySubScreen + { + [BackgroundDependencyLoader] + private void load() + { + CenterColumn.Children = + [ + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(20), + Children = + [ + new OsuSpriteText + { + Text = "Gameplay is in progress...", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.GetFont(typeface: Typeface.TorusAlternate, size: 42, weight: FontWeight.Regular), + }, + ] + }, + ]; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs new file mode 100644 index 0000000000..65af9c57a3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs @@ -0,0 +1,234 @@ +// 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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class DifficultyDisplay : CompositeDrawable + { + private const float border_weight = 2; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private readonly APIBeatmap beatmap; + + private StarRatingDisplay starRatingDisplay = null!; + private FillFlowContainer nameLine = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText mappedByText = null!; + private OsuSpriteText mapperText = null!; + + private BeatmapTitleWedge.DifficultyStatisticsDisplay countStatisticsDisplay = null!; + private BeatmapTitleWedge.DifficultyStatisticsDisplay difficultyStatisticsDisplay = null!; + + public DifficultyDisplay(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 10; + Shear = OsuGame.SHEAR; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(new GridContainer + { + Shear = -OsuGame.SHEAR, + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + Height = 20, + Margin = new MarginPadding { Vertical = 5f }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 6), + new Dimension(), + }, + Content = new[] + { + new[] + { + starRatingDisplay = new StarRatingDisplay(default, animated: true) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + nameLine = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Bottom = 2f }, + Children = new Drawable[] + { + difficultyText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + mappedByText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " mapped by ", + Font = OsuFont.Style.Body, + }, + mapperText = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + } + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = 53, + Padding = new MarginPadding { Bottom = border_weight, Right = border_weight }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 10 - border_weight, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.8f), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f }, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + countStatisticsDisplay = new BeatmapTitleWedge.DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + difficultyStatisticsDisplay = new BeatmapTitleWedge.DifficultyStatisticsDisplay(autoSize: true), + } + }, + } + }, + } + }), + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + BeatmapInfo? localBeatmap = + beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + WorkingBeatmap workingBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); + IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset); + + difficultyText.Text = beatmap.DifficultyName; + mapperText.Text = beatmap.Metadata.Author.Username; + starRatingDisplay.Current.Value = new StarDifficulty(beatmap.StarRating, beatmap.MaxCombo ?? 0); + + countStatisticsDisplay.Statistics = playableBeatmap.GetStatistics() + .Select(s => new BeatmapTitleWedge.StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + + difficultyStatisticsDisplay.Statistics = rulesetInstance.GetBeatmapAttributesForDisplay(beatmap, []) + .Select(a => new BeatmapTitleWedge.StatisticDifficulty.Data(a)) + .ToList(); + } + + protected override void Update() + { + base.Update(); + + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + + // Use difficulty colour until it gets too dark to be visible against dark backgrounds. + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + + difficultyText.Colour = col; + mappedByText.Colour = col; + countStatisticsDisplay.AccentColour = col; + difficultyStatisticsDisplay.AccentColour = col; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs new file mode 100644 index 0000000000..7ffb1fba6a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs @@ -0,0 +1,260 @@ +// 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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class MetadataWedge : CompositeDrawable + { + private readonly APIBeatmap beatmap; + + private BeatmapMetadataWedge.MetadataDisplay creator = null!; + private BeatmapMetadataWedge.MetadataDisplay source = null!; + private BeatmapMetadataWedge.MetadataDisplay genre = null!; + private BeatmapMetadataWedge.MetadataDisplay language = null!; + private BeatmapMetadataWedge.MetadataDisplay userTags = null!; + private BeatmapMetadataWedge.MetadataDisplay mapperTags = null!; + private BeatmapMetadataWedge.MetadataDisplay submitted = null!; + private BeatmapMetadataWedge.MetadataDisplay ranked = null!; + + private BeatmapMetadataWedge.SuccessRateDisplay successRateDisplay = null!; + private BeatmapMetadataWedge.UserRatingDisplay userRatingDisplay = null!; + private BeatmapMetadataWedge.RatingSpreadDisplay ratingSpreadDisplay = null!; + private BeatmapMetadataWedge.FailRetryDisplay failRetryDisplay = null!; + + public MetadataWedge(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Width = 0.9f; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Shear = OsuGame.SHEAR, + Children = new[] + { + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 35, Vertical = 16 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + creator = new BeatmapMetadataWedge.MetadataDisplay(EditorSetupStrings.Creator), + genre = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoGenre), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + source = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoSource), + language = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoLanguage), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + submitted = new BeatmapMetadataWedge.MetadataDisplay(SongSelectStrings.Submitted), + ranked = new BeatmapMetadataWedge.MetadataDisplay(SongSelectStrings.Ranked), + }, + }, + }, + }, + }, + userTags = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoUserTags) + { + Alpha = 0, + }, + mapperTags = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoMapperTags), + }, + }, + }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Content = new[] + { + new[] + { + successRateDisplay = new BeatmapMetadataWedge.SuccessRateDisplay(), + Empty(), + userRatingDisplay = new BeatmapMetadataWedge.UserRatingDisplay(), + Empty(), + ratingSpreadDisplay = new BeatmapMetadataWedge.RatingSpreadDisplay(), + }, + }, + }, + } + }), + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Child = failRetryDisplay = new BeatmapMetadataWedge.FailRetryDisplay(), + }, + }, + }), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var metadata = beatmap.Metadata; + var beatmapSet = beatmap.BeatmapSet!; + + creator.Data = (metadata.Author.Username, null); + + if (!string.IsNullOrEmpty(metadata.Source)) + source.Data = (metadata.Source, null); + else + source.Data = ("-", null); + + if (!string.IsNullOrEmpty(metadata.Tags)) + mapperTags.Tags = (metadata.Tags.Split(' '), _ => { }); + else + mapperTags.Tags = (Array.Empty(), _ => { }); + + submitted.Date = beatmapSet.Submitted; + ranked.Date = beatmapSet.Ranked; + + genre.Data = (beatmapSet.Genre.Name, null); + language.Data = (beatmapSet.Language.Name, null); + + userRatingDisplay.Data = beatmapSet.Ratings; + ratingSpreadDisplay.Data = beatmapSet.Ratings; + successRateDisplay.Data = (beatmap.PassCount, beatmap.PlayCount); + failRetryDisplay.Data = beatmap.FailTimes ?? new APIFailTimes(); + + var tagsById = beatmapSet.RelatedTags?.ToDictionary(t => t.Id) ?? new Dictionary(); + string[] topUserTags = beatmap.TopTags? + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!.Name) + .ToArray() ?? []; + + userTags.Tags = (topUserTags, _ => { }); + + if (topUserTags.Length > 0) + userTags.Show(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs new file mode 100644 index 0000000000..381b12bbf8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs @@ -0,0 +1,167 @@ +// 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.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Select; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class TitleWedge : CompositeDrawable + { + private const float corner_radius = 10; + + private readonly APIBeatmap beatmap; + + private BeatmapSetOnlineStatusPill statusPill = null!; + private MarqueeContainer titleLabel = null!; + private MarqueeContainer artistLabel = null!; + + private BeatmapTitleWedge.StatisticPlayCount playCount = null!; + private BeatmapTitleWedge.FavouriteButton favouriteButton = null!; + private BeatmapTitleWedge.Statistic lengthStatistic = null!; + private BeatmapTitleWedge.Statistic bpmStatistic = null!; + + public TitleWedge(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + Shear = OsuGame.SHEAR; + CornerRadius = corner_radius; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding + { + Top = SongSelect.WEDGE_CONTENT_MARGIN, + Left = SongSelect.WEDGE_CONTENT_MARGIN + }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new ShearAligningWrapper(statusPill = new BeatmapSetOnlineStatusPill + { + Shear = -OsuGame.SHEAR, + ShowUnknownStatus = true, + TextSize = OsuFont.Style.Caption1.Size, + TextPadding = new MarginPadding { Horizontal = 6, Vertical = 1 }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Title.Size, + Margin = new MarginPadding { Bottom = -4f }, + Child = titleLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Heading2.Size, + Margin = new MarginPadding { Left = 1f }, + Child = artistLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + }), + new ShearAligningWrapper(new FillFlowContainer + { + Shear = -OsuGame.SHEAR, + AutoSizeAxes = Axes.X, + Height = 30, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + Children = new Drawable[] + { + playCount = new BeatmapTitleWedge.StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) + { + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + }, + favouriteButton = new BeatmapTitleWedge.FavouriteButton(), + lengthStatistic = new BeatmapTitleWedge.Statistic(OsuIcon.Clock), + bpmStatistic = new BeatmapTitleWedge.Statistic(OsuIcon.Metronome) + { + TooltipText = BeatmapsetsStrings.ShowStatsBpm, + Margin = new MarginPadding { Left = 5f }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + Padding = new MarginPadding { Right = -SongSelect.WEDGE_CONTENT_MARGIN }, + Child = new DifficultyDisplay(beatmap), + }), + }, + } + }; + + statusPill.Status = beatmap.Status; + + var titleText = new RomanisableString(beatmap.BeatmapSet!.TitleUnicode, beatmap.BeatmapSet.Title); + titleLabel.CreateContent = () => new OsuSpriteText + { + Text = titleText, + Shadow = true, + Font = OsuFont.Style.Title, + }; + + var artistText = new RomanisableString(beatmap.BeatmapSet.ArtistUnicode, beatmap.BeatmapSet.Artist); + artistLabel.CreateContent = () => new OsuSpriteText + { + Text = artistText, + Shadow = true, + Font = OsuFont.Style.Heading2, + }; + + double rate = ModUtils.CalculateRateWithMods([]); // Todo: mods + double drainLength = Math.Round(beatmap.Length / rate); + double hitLength = Math.Round(beatmap.HitLength / rate); + + lengthStatistic.Text = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + bpmStatistic.Text = beatmap.BPM.ToLocalisableString(); + + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(beatmap.PlayCount, beatmap.UserPlayCount); + favouriteButton.SetBeatmapSet(beatmap.BeatmapSet); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs new file mode 100644 index 0000000000..80f4bb4ad5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs @@ -0,0 +1,182 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen : RankedPlaySubScreen + { + public override bool ShowBeatmapBackground => true; + + [Cached(typeof(IBindable))] + private readonly Bindable lastLookupResult = new Bindable(); + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + [Resolved] + private OverlayColourProvider overlayColours { get; set; } = null!; + + private Container cardColumn = null!; + private Drawable separator = null!; + private Drawable detailsColumn = null!; + private Drawable wedgesContainer = null!; + + [BackgroundDependencyLoader] + private void load() + { + APIBeatmap beatmap = beatmapLookupCache.GetBeatmapAsync(Client.Room!.CurrentPlaylistItem.BeatmapID).GetResultSafely()!; + lastLookupResult.Value = SongSelect.BeatmapSetLookupResult.Completed(beatmap.BeatmapSet); + + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + Debug.Assert(matchState != null); + + Children = + [ + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Spacing = new Vector2(20), + LayoutDuration = 500, + LayoutEasing = Easing.OutPow10, + Children = new[] + { + cardColumn = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + }, + separator = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(2, 50), + Scale = new Vector2(1, 0), + Alpha = 0, + Colour = overlayColours.Colour0 + }, + detailsColumn = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + Scale = new Vector2(0.8f), + Alpha = 0, + Child = wedgesContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + X = -20, + Padding = new MarginPadding + { + Left = -SongSelect.CORNER_RADIUS_HIDE_OFFSET, + }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 4f), + Direction = FillDirection.Vertical, + Children = + [ + new ShearAligningWrapper(new TitleWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + new ShearAligningWrapper(new MetadataWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + ] + } + } + } + } + } + ]; + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + if (matchInfo.LastPlayedCard == null) + return; + + RankedPlayCard? card = null; + + switch (previous) + { + case PickScreen pick: + { + if (pick.CenterRow.RemoveCard(matchInfo.LastPlayedCard, out card, out var screenSpaceDrawQuad)) + card.MatchScreenSpaceDrawQuad(screenSpaceDrawQuad, cardColumn); + break; + } + + case OpponentPickScreen opponentPick: + { + if (opponentPick.CenterRow.RemoveCard(matchInfo.LastPlayedCard, out card, out var screenSpaceDrawQuad)) + card.MatchScreenSpaceDrawQuad(screenSpaceDrawQuad, cardColumn); + break; + } + } + + if (card == null) + { + Logger.Log($"Played card {matchInfo.LastPlayedCard.Card.ID} was not on the screen.", level: LogLevel.Error); + + card = new RankedPlayCard(matchInfo.LastPlayedCard) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + cardColumn.Add(card); + + separator.AlwaysPresent = true; + detailsColumn.AlwaysPresent = true; + + using (BeginDelayedSequence(500)) + { + separator.FadeIn(); + separator.ScaleTo(Vector2.One, 1000, Easing.OutPow10); + + using (BeginDelayedSequence(200)) + { + detailsColumn.FadeIn(800, Easing.OutPow10); + wedgesContainer.MoveToX(0, 1000, Easing.OutPow10); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs new file mode 100644 index 0000000000..2d422b5252 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs @@ -0,0 +1,116 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class HamburgerMenu : IconButton, IHasPopover + { + public HamburgerMenu() + { + Icon = FontAwesome.Solid.Bars; + Action = this.ShowPopover; + } + + public Framework.Graphics.UserInterface.Popover GetPopover() => new Popover(); + + private partial class Popover : OsuPopover + { + [Resolved] + private RankedPlayScreen? rankedPlayScreen { get; set; } + + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + private FillFlowContainer buttonFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + Content.Padding = new MarginPadding(5); + + Child = buttonFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(3), + }; + + addButton(rankedPlayScreen?.ActiveSubScreen is not EndedScreen ? "Give up" : "Exit", FontAwesome.Solid.SignOutAlt, () => rankedPlayScreen?.Exit()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(this)); + } + + private void addButton(LocalisableString text, IconUsage? icon, Action? action, Color4? colour = null) + { + var button = new OptionButton + { + Text = text, + Icon = icon ?? new IconUsage(), + BackgroundColour = colourProvider.Background3, + TextColour = colour, + Action = () => + { + Scheduler.AddDelayed(Hide, 50); + action?.Invoke(); + }, + }; + + buttonFlow.Add(button); + } + + private partial class OptionButton : OsuButton + { + public IconUsage Icon { get; init; } + public Color4? TextColour { get; init; } + + public OptionButton() + { + Size = new Vector2(265, 50); + } + + [BackgroundDependencyLoader] + private void load() + { + SpriteText.Colour = TextColour ?? Color4.White; + Content.CornerRadius = 10; + + Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(17), + X = 15, + Icon = Icon, + Colour = TextColour ?? Color4.White, + }); + } + + protected override SpriteText CreateText() => new OsuSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 40 + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs new file mode 100644 index 0000000000..43e8cbf270 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs @@ -0,0 +1,113 @@ +// 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.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public abstract partial class HandOfCards + { + public partial class HandCard : CompositeDrawable + { + public float LayoutWidth => DrawWidth * (State.Hovered ? hover_scale : 1); + + private readonly Bindable state = new Bindable(); + + public RankedPlayCardState State + { + get => state.Value; + set => state.Value = value; + } + + public bool Selected + { + get => State.Selected; + set => State = State with { Selected = value }; + } + + public bool CardHovered + { + get => State.Hovered; + set => State = State with { Hovered = value }; + } + + public bool CardPressed + { + get => State.Pressed; + set => State = State with { Pressed = value }; + } + + [Resolved] + private HandOfCards handOfCards { get; set; } = null!; + + public readonly RankedPlayCard Card; + + public RankedPlayCardWithPlaylistItem Item => Card.Item; + + public HandCard(RankedPlayCard card) + { + Size = card.DrawSize; + + card.Anchor = Anchor.Centre; + card.Origin = Anchor.Centre; + card.Position = Vector2.Zero; + card.Rotation = 0; + card.Scale = Vector2.One; + + AddInternal(Card = card); + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + state.BindValueChanged(OnStateChanged, true); + } + + protected virtual void OnStateChanged(ValueChangedEvent state) + { + handOfCards.OnCardStateChanged(this, state.NewValue); + + Card.ShowSelectionOutline = state.NewValue.Selected; + + switch (state.NewValue.Pressed, state.OldValue.Pressed) + { + case (true, false): + Card.ScaleTo(0.95f, 300, Easing.OutExpo); + break; + + case (false, true): + Card.ScaleTo(1f, 400, Easing.OutElasticHalf); + break; + } + } + + public RankedPlayCard Detach() + { + Card.ShowSelectionOutline = false; + Card.Elevation = 0; + + RemoveInternal(Card, false); + + return Card; + } + + protected override void Update() + { + base.Update(); + + Card.Elevation = float.Lerp(CardHovered ? 1 : 0, Card.Elevation, (float)Math.Exp(-0.03f * Time.Elapsed)); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs new file mode 100644 index 0000000000..935d8fb3b8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs @@ -0,0 +1,255 @@ +// 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 System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Drawable that layouts cards as if held in a player's hands. + /// + [Cached] + public abstract partial class HandOfCards : CompositeDrawable + { + private const float hover_scale = 1.2f; + + public IEnumerable 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; + + /// + /// 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. + /// Used for . + /// + protected virtual bool Flipped => false; + + private readonly Container cardContainer; + + private readonly Dictionary cardLookup = new Dictionary(); + + protected HandOfCards() + { + AddInternal(cardContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }); + } + + protected override void Update() + { + base.Update(); + + if (!layoutBacking.IsValid) + { + updateLayout(); + layoutBacking.Validate(); + } + } + + protected bool Contracted { get; private set; } + + /// + /// Contracts all cards towards the bottom (or top when ). + /// Cards will no longer get layouted after this method is called. + /// + public void Contract() + { + Contracted = true; + + double delay = 0; + + 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); + + 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) + { + if (cardLookup.ContainsKey(card.Item.Card)) + return; + + var drawable = CreateHandCard(card); + drawable.Anchor = drawable.Origin = cardAnchor; + + cardLookup[card.Item.Card] = drawable; + + cardContainer.Add(drawable); + layoutBacking.Invalidate(); + + setupAction?.Invoke(drawable); + } + + public void Clear() => cardContainer.Clear(); + + public bool RemoveCard(RankedPlayCardWithPlaylistItem item) + { + if (!cardLookup.Remove(item.Card, out var drawable)) + return false; + + cardContainer.Remove(drawable, true); + layoutBacking.Invalidate(); + return false; + } + + /// + /// Removes a card and detaches it's contained card so it can be attached to a new card facade. + /// + /// Item to remove the card for + /// Contained + /// of the removed card + /// Whether a card was found for the provided item + public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false)] out RankedPlayCard card, out Quad screenSpaceDrawQuad) + { + if (!cardLookup.Remove(item.Card, out var drawable)) + { + card = null; + screenSpaceDrawQuad = default; + return false; + } + + screenSpaceDrawQuad = drawable.ScreenSpaceDrawQuad; + card = drawable.Detach(); + + cardContainer.Remove(drawable, true); + layoutBacking.Invalidate(); + + return true; + } + + protected virtual HandCard CreateHandCard(RankedPlayCard card) => new HandCard(card); + + protected virtual void OnCardStateChanged(HandCard card, RankedPlayCardState state) + { + InvalidateLayout(); + + // hovered state can be caused by keyboard focus, in which case we have to clean up after the other cards manually + if (state.Hovered) + { + foreach (var c in cardContainer) + { + if (c != card) + c.CardHovered = false; + } + } + } + + #region Layout + + private readonly Cached layoutBacking = new Cached(); + + protected void InvalidateLayout() => layoutBacking.Invalidate(); + + public void UpdateLayout(double stagger = 0) + { + updateLayout(stagger); + layoutBacking.Validate(); + } + + private void updateLayout(double stagger = 0) + { + if (Contracted) + return; + + const float spacing = -20; + + float totalWidth = cardContainer.Sum(it => it.LayoutWidth + spacing) - spacing; + + float x = -totalWidth / 2; + + const int no_card_hovered = -1; + int hoverIndex = no_card_hovered; + + for (int i = 0; i < cardContainer.Count; i++) + { + if (cardContainer[i].CardHovered) + { + hoverIndex = i; + break; + } + } + + double delay = 0; + + for (int i = 0; i < cardContainer.Count; i++) + { + var child = cardContainer[i]; + + x += child.LayoutWidth / 2; + + float yOffset = 0; + + var position = new Vector2(x, MathF.Pow(MathF.Abs(x / 250), 2) * 20 - 10); + + if (hoverIndex != no_card_hovered && cardContainer.Children.Count > 1) + { + int distance = Math.Abs(i - hoverIndex); + int direction = Math.Sign(i - hoverIndex); + + position.X += direction switch + { + 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, + + < 0 => -10 / MathF.Pow(distance, 3), + + // 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), + }; + } + + 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; + } + } + + #endregion + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs new file mode 100644 index 0000000000..e7c6fe0ec8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs @@ -0,0 +1,72 @@ +// 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.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public partial class HandReplayPlayer : Component + { + /// + /// Maximum amount of frames that can get queued up at the same time + /// + public int MaxQueuedFrames { get; set; } = 20; + + private readonly int userId; + private readonly OpponentHandOfCards handOfCards; + + private int queuedFrames; + private double? lastPlayback; + + public HandReplayPlayer(int userId, OpponentHandOfCards handOfCards) + { + this.userId = userId; + this.handOfCards = handOfCards; + } + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchEvent += onMatchEvent; + } + + private void onMatchEvent(MatchServerEvent e) + { + if (e is not RankedPlayCardHandReplayEvent replayEvent || replayEvent.UserId != userId) + return; + + foreach (var frame in replayEvent.Frames) + { + if (queuedFrames >= MaxQueuedFrames) + return; + + queuedFrames++; + + double delay = Math.Max(lastPlayback != null ? lastPlayback.Value + frame.Delay - Time.Current : 0, 0); + lastPlayback = Time.Current + delay; + + Scheduler.AddDelayed(() => + { + queuedFrames--; + + handOfCards.SetState(frame.Cards); + }, delay); + } + } + + protected override void Dispose(bool isDisposing) + { + client.MatchEvent -= onMatchEvent; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs new file mode 100644 index 0000000000..9c4bf1d1cb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public partial class HandReplayRecorder : Component + { + /// + /// Interval at which buffered frames get collected and emitted + /// + public double FlushInterval { get; init; } = 1000; + + /// + /// Minimum interval between individual replay frames + /// + public double RecordInterval { get; init; } = 25; + + /// + /// Max amount of frames to collect per + /// + public int MaxBufferSize = 20; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly PlayerHandOfCards handOfCards; + + private readonly List buffer = new List(); + private bool hasChanges; + private double? lastFrameTime; + + public HandReplayRecorder(PlayerHandOfCards handOfCards) + { + this.handOfCards = handOfCards; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scheduler.AddDelayed(recordFrame, RecordInterval, true); + Scheduler.AddDelayed(tryFlush, FlushInterval, true); + + handOfCards.StateChanged += onHandOfCardsStateChanged; + } + + private void onHandOfCardsStateChanged() => hasChanges = true; + + private void recordFrame() + { + if (!hasChanges || buffer.Count >= MaxBufferSize) + return; + + double delay = lastFrameTime != null ? Time.Current - lastFrameTime.Value : 0; + + buffer.Add(new RankedPlayCardHandReplayFrame + { + Delay = delay, + Cards = handOfCards.State, + }); + + lastFrameTime = Time.Current; + hasChanges = false; + } + + private void tryFlush() + { + if (buffer.Count == 0) + return; + + var frames = compress(buffer).ToArray(); + buffer.Clear(); + + if (frames.Length > 0) + Flush(frames); + } + + /// + /// Compresses a list of s by only keeping values that have changed between each frame + /// + private IEnumerable compress(IReadOnlyList frames) + { + if (frames.Count == 0) + yield break; + + // The first frame always contains the full state since the replay player may drop frames starting from the end for each message. + yield return frames[0]; + + var lastFrame = frames[0]; + + foreach (var frame in frames.Skip(1)) + { + yield return frame.RelativeTo(lastFrame); + + lastFrame = frame; + } + } + + protected virtual void Flush(RankedPlayCardHandReplayFrame[] frames) + { + if (frames.Length == 0) + return; + + client.SendMatchRequest(new RankedPlayCardHandReplayRequest + { + Frames = frames, + }); + } + + protected override void Dispose(bool isDisposing) + { + handOfCards.StateChanged -= onHandOfCardsStateChanged; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs new file mode 100644 index 0000000000..4a80913314 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public enum HandSelectionMode + { + Disabled, + Single, + Multiple, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs new file mode 100644 index 0000000000..e2e27d820d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs @@ -0,0 +1,28 @@ +// 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 System.Collections.Generic; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Card hand representing the opponent's current hand, intended to be placed at the top edge of the screen. + /// + public partial class OpponentHandOfCards : HandOfCards + { + protected override bool Flipped => true; + + public void SetState(Dictionary state) + { + foreach (var card in Cards) + { + if (!state.TryGetValue(card.Item.Card.ID, out var cardState)) + continue; + + card.State = cardState; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs new file mode 100644 index 0000000000..c4021d7df9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs @@ -0,0 +1,170 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +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 +{ + public partial class PlayerHandOfCards + { + public partial class PlayerHandCard : HandCard + { + private Action? playAction; + + public Action? PlayAction + { + get => playAction; + set + { + playAction = value; + PlayButton.Action = value; + updatePlayButtonVisibility(); + } + } + + public required Action Clicked; + + public required IBindable AllowSelection; + + private readonly Drawable cardInputArea; + private readonly Drawable fullInputArea; + + public readonly ShearedButton PlayButton; + + public PlayerHandCard(RankedPlayCard card) + : base(card) + { + AddRangeInternal(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(-10) + { + // card moves upwards on hover which can produce jitter if the hitbox doesn't extend all the way to the bottom of the screen + Bottom = -50 + }, + Child = cardInputArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = -40 }, + Child = fullInputArea = new Container + { + RelativeSizeAxes = Axes.Both, + Child = PlayButton = new ShearedButton + { + Name = "Play Button", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(90, 30), + Text = "Play", + TextSize = 14, + LighterColour = Colour4.FromHex("87D8FA"), + DarkerColour = Colour4.FromHex("72D5FF") + } + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddInternal(new HoverSounds()); + } + + protected override void OnStateChanged(ValueChangedEvent state) + { + base.OnStateChanged(state); + updatePlayButtonVisibility(); + } + + private void updatePlayButtonVisibility() + { + PlayButton.Alpha = PlayButton.Action != null && Selected ? 1 : 0; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (PlayButton.Alpha > 0) + return fullInputArea.ReceivePositionalInputAt(screenSpacePos); + + // input events are handled for an area that's slightly larger than the actual card so the cursor always hovers a card when moving over a gap between two cards + return cardInputArea.ReceivePositionalInputAt(screenSpacePos); + } + + protected override bool OnHover(HoverEvent e) + { + CardHovered = true; + + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + CardHovered = false; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left && AllowSelection.Value) + { + CardPressed = true; + + return true; + } + + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Left) + CardPressed = false; + } + + protected override bool OnClick(ClickEvent e) + { + if (!AllowSelection.Value) + return false; + + Clicked(this); + + return true; + } + + public override bool AcceptsFocus => true; + + public override bool ChangeFocusOnClick => false; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + CardHovered = true; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + CardHovered = false; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs new file mode 100644 index 0000000000..7ec73b7662 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs @@ -0,0 +1,219 @@ +// 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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Audio; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Card hand representing the player's current hand, intended to be placed at the bottom edge of the screen. + /// This version of the card hand reacts to player inputs like hovering a card. + /// + public partial class PlayerHandOfCards : HandOfCards + { + /// + /// Fired if any card is selected or deselected + /// + public event Action? SelectionChanged; + + /// + /// Fired if a card's has changed + /// + public event Action? StateChanged; + + private HandSelectionMode selectionMode; + + /// + /// Current selection mode. + /// + /// + /// will disable some of the card's mouse interactions. + /// + public HandSelectionMode SelectionMode + { + get => selectionMode; + set + { + selectionMode = value; + allowSelection.Value = value != HandSelectionMode.Disabled; + + if (value == HandSelectionMode.Disabled) + { + foreach (var card in Cards) + card.Selected = false; + } + } + } + + private Action? playCardAction; + + /// + /// When set to non-null, displays a "Play" button on the selected card that invokes this action. + /// + public Action? PlayCardAction + { + get => playCardAction; + set + { + playCardAction = value; + + foreach (var card in Cards.OfType()) + card.PlayAction = value; + } + } + + private IEnumerable selection => Cards.OfType().Where(it => it.Selected); + + /// + /// Currently selected cards. + /// + public IEnumerable Selection => selection.Select(it => it.Card.Item); + + private readonly BindableBool allowSelection = new BindableBool(); + + private const int select_samples = 1; + private const int deselect_samples = 2; + + private Sample?[]? cardSelectSamples; + private Sample?[]? cardDeselectSamples; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + cardSelectSamples = new Sample?[select_samples]; + for (int i = 0; i < select_samples; i++) + cardSelectSamples[i] = audio.Samples.Get(@$"Multiplayer/Matchmaking/Ranked/card-select-{i + 1}"); + + cardDeselectSamples = new Sample?[deselect_samples]; + for (int i = 0; i < deselect_samples; i++) + cardDeselectSamples[i] = audio.Samples.Get(@$"Multiplayer/Matchmaking/Ranked/card-deselect-{i + 1}"); + } + + protected override HandCard CreateHandCard(RankedPlayCard card) => new PlayerHandCard(card) + { + Clicked = cardClicked, + AllowSelection = allowSelection.GetBoundCopy(), + PlayAction = PlayCardAction, + }; + + private void cardClicked(PlayerHandCard card) + { + if (selectionMode == HandSelectionMode.Disabled) + return; + + try + { + if (selectionMode == HandSelectionMode.Single) + { + // only play feedback SFX if the selected card has changed + if (!card.Selected) + SamplePlaybackHelper.PlayWithRandomPitch(cardSelectSamples); + + foreach (var c in Cards) + { + ((PlayerHandCard)c).Selected = c == card; + } + + return; + } + + card.Selected = !card.Selected; + + SamplePlaybackHelper.PlayWithRandomPitch(card.Selected ? cardSelectSamples : cardDeselectSamples); + } + finally + { + SelectionChanged?.Invoke(); + } + } + + protected override void OnCardStateChanged(HandCard card, RankedPlayCardState state) + { + StateChanged?.Invoke(); + + base.OnCardStateChanged(card, state); + } + + 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) + return false; + + switch (e.Key) + { + case >= Key.Number1 and <= Key.Number9: + focusCard(e.Key - Key.Number1); + return true; + + case Key.Space: + if (selectionMode == HandSelectionMode.Disabled) + return false; + + if (Cards.FirstOrDefault(it => it.HasFocus) is not PlayerHandCard card) + return false; + + if (card.Selected) + card.PlayButton.TriggerClick(); + else + card.TriggerClick(); + + return true; + + case Key.Left: + moveCardFocus(-1); + return true; + + case Key.Right: + moveCardFocus(1); + return true; + } + + return base.OnKeyDown(e); + } + + private void moveCardFocus(int direction) + { + int currentIndex = Cards.ToList().FindIndex(c => c.HasFocus); + + // default behaviour is to start from either end of the cards if no card is focused currently + // in single-selection mode we can however use the current selection as a fallback index if there's no focus + if (selectionMode == HandSelectionMode.Single && currentIndex == -1) + currentIndex = Cards.ToList().FindIndex(c => c.Selected); + + int newIndex = currentIndex + direction; + + if (newIndex < 0) + newIndex = Cards.Count() - 1; + else if (newIndex >= Cards.Count()) + newIndex = 0; + + focusCard(newIndex); + } + + private void focusCard(int index) + { + var card = Cards.ElementAtOrDefault(index); + + if (card == null) + return; + + GetContainingFocusManager()?.ChangeFocus(card); + + if (SelectionMode == HandSelectionMode.Single && !card.Selected) + card.TriggerClick(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs new file mode 100644 index 0000000000..fe25ab8501 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics.Backgrounds; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class CoverReveal : CompositeDrawable + { + private readonly Container content; + private readonly Box bottomLayer; + private readonly Box middleLayer; + private readonly Box topLayer; + private readonly TrianglesV2 triangles; + + public CoverReveal(RankedPlayColourScheme colourScheme) + { + Padding = new MarginPadding { Horizontal = 100 }; + Masking = true; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -50 }, + Children = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + ClampAxes = Axes.None, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.White.Opacity(0)), + }, + bottomLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-0.1f, 0), + Colour = ColourInfo.GradientVertical(colourScheme.PrimaryDarkest, colourScheme.PrimaryDarkest.Opacity(0)), + Alpha = 0.5f, + }, + middleLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(0.1f, 0), + Colour = ColourInfo.GradientVertical(colourScheme.PrimaryDarker, colourScheme.Primary.Opacity(0.5f)), + Alpha = 0.75f, + }, + topLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Primary, + Shear = new Vector2(-0.1f, 0) + }, + ] + }, + ] + }; + } + + protected override void Update() + { + base.Update(); + + Padding = new MarginPadding + { + Horizontal = -DrawHeight * 0.25f / 2 + }; + } + + public void Play() + { + content.MoveToX(50) + .MoveToX(-50, 4000); + + triangles.MoveToX(-0.75f, 3500, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .FadeOut(2000); + + topLayer.ResizeWidthTo(0.0f, 2800, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(0.1f, 0), 2800, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart); + + middleLayer + .Delay(50) + .ResizeWidthTo(0.15f, 2900, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(-0.15f, 0), 2900, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart) + .TransformTo(nameof(Shear), new Vector2(-0.25f, 0), 500, Easing.InCubic); + + bottomLayer + .Delay(100) + .ResizeWidthTo(0.2f, 3000, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(0.3f, 0), 3000, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart) + .TransformTo(nameof(Shear), new Vector2(0.5f, 0), 500, Easing.InCubic); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs new file mode 100644 index 0000000000..87fac18026 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs @@ -0,0 +1,123 @@ +// 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 System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class IntroScreen : RankedPlaySubScreen + { + public IntroScreen() + { + CornerPieceVisibility.Value = Visibility.Hidden; + } + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MusicController? musicController { get; set; } + + private Sample? windupSample; + private Sample? impactSample; + private Sample? swooshSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + windupSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-windup"); + impactSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-impact"); + swooshSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-swoosh"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadUsers().FireAndForget(); + } + + private async Task loadUsers() + { + var roomState = ((RankedPlayRoomState)Client.Room!.MatchState!); + + int[] userIds = roomState.Users.Keys.ToArray(); + + var users = await userLookupCache.GetUsersAsync(userIds).ConfigureAwait(false); + + var player = users.OfType().First(it => it.Id == api.LocalUser.Value.Id); + var opponent = users.OfType().First(it => it.Id != api.LocalUser.Value.Id); + + int playerRating = roomState.Users[player.Id].Rating; + int opponentRating = roomState.Users[opponent.Id].Rating; + + Schedule(() => PlayIntroSequence( + new UserWithRating(player, playerRating), + new UserWithRating(opponent, opponentRating), + roomState.StarRating + )); + } + + private StarRatingSequence? starRatingAnimation; + + private IDisposable? duckOperation; + + public void PlayIntroSequence(UserWithRating player, UserWithRating opponent, double starRating) + { + double delay = 0; + + var vsScreen = new VsSequence(player, opponent); + + starRatingAnimation = new StarRatingSequence(); + + AddRangeInternal([vsScreen, starRatingAnimation]); + + vsScreen.Play(ref delay, out double impactDelay); + + duckOperation = musicController?.Duck(); + + if (windupSample != null) + { + Scheduler.AddDelayed(() => windupSample?.Play(), impactDelay - windupSample.Length); + Scheduler.AddDelayed(() => impactSample?.Play(), impactDelay); + Scheduler.AddDelayed(() => swooshSample?.Play(), impactDelay + 3200); + } + + Scheduler.AddDelayed(() => CornerPieceVisibility.Value = Visibility.Visible, delay); + + starRatingAnimation.Play(ref delay, (float)starRating); + } + + public override void OnExiting(RankedPlaySubScreen? next) + { + starRatingAnimation?.PopOut(); + + duckOperation?.Dispose(); + + this.Delay(500).FadeOut(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + duckOperation?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs new file mode 100644 index 0000000000..d4f64bdfc7 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs @@ -0,0 +1,341 @@ +// 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.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class StarRatingSequence : CompositeDrawable + { + private Container bars = null!; + private Container starContainer = null!; + private Container centerContainer = null!; + private OsuSpriteText title = null!; + private OsuSpriteText creatingMapPool = null!; + private OsuSpriteText explainer = null!; + + private Sample? tickSample; + private Sample? tickFinalSample; + private Sample? ratingFoundSample; + private Sample? noticeSample; + + private float lastTickStdDev; + + [BackgroundDependencyLoader] + private void load(OsuColour colour, AudioManager audio) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Alpha = 0; + + Padding = new MarginPadding { Horizontal = 100 }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = + [ + title = new OsuSpriteText + { + Text = "Finding Match Rating...", + Font = OsuFont.Style.Title, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + creatingMapPool = new OsuSpriteText + { + Text = "Creating a mappool...", + Font = OsuFont.Style.Heading1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Bottom = 30 }, + Alpha = 0, + AlwaysPresent = true, + }, + centerContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 90, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = + [ + bars = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 30 }, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + ] + }, + starContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 20, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + explainer = new OsuSpriteText + { + Text = "There’s still a chance that you get maps outside of the selected match rating!", + Font = OsuFont.Style.Heading2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 20 }, + Alpha = 0, + AlwaysPresent = true, + } + ], + }; + + for (int i = 0; i < 100; i++) + { + float difficulty = i / 10f; + + bars.Add(new Bar + { + StarRating = difficulty, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = difficulty / 10f, + Width = 0.0075f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = colour.ForStarDifficulty(Math.Max(difficulty, 0.1)), + Height = 0 + }); + + if (i > 0 && i % 10 == 0) + { + var starRatingDisplay = new StarRatingDisplay(new StarDifficulty(difficulty, 0), StarRatingDisplaySize.Small) + { + RelativePositionAxes = Axes.X, + X = difficulty / 10f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Scale = new Vector2(0) + }; + + starContainer.Add(starRatingDisplay); + } + } + + tickSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-tick"); + tickFinalSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-tick-final"); + ratingFoundSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-found"); + noticeSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-notice"); + } + + private float starRating { get; set; } = 5; + + private float amplitude { get; set; } = 0; + + private float stdDev { get; set; } = 6; + + private bool animateGaussianCurve; + + public void Play(ref double delay, float starRating) + { + using (BeginDelayedSequence(delay)) + { + popIn(); + } + + delay += 500; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => animateGaussianCurve = true); + + this.TransformTo(nameof(starRating), starRating < 5 ? starRating + 4 : starRating - 4); + this.TransformTo(nameof(starRating), starRating, 4000, new CubicBezierEasingFunction(easeIn: 0.3, easeOut: 0.5)); + this.TransformTo(nameof(amplitude), 1f, 4000, new CubicBezierEasingFunction(easeIn: 0.1, easeOut: 0.8)); + this.TransformTo(nameof(stdDev), 0.3f, 4500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.7)); + } + + delay += 5000; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + animateGaussianCurve = false; + + ratingFoundSample?.Play(); + + var container = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Anchor = Anchor.TopLeft, + Origin = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = starRating * 0.1f, + Y = 24, + Colour = Color4Extensions.FromHex("#FFE280"), + Spacing = new Vector2(4, 0), + Children = + [ + new OsuSpriteText + { + Text = FormattableString.Invariant($"~{starRating:F2}"), + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.Star, + Size = new Vector2(19), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ] + }; + + centerContainer.Add(container); + + container.FadeInFromZero(200) + .ScaleTo(0) + .ScaleTo(1, 400, Easing.OutElasticQuarter); + + title.Text = "Match rating found!"; + + creatingMapPool.FadeIn(100); + explainer.Delay(1050).FadeIn(100); + Scheduler.AddDelayed(() => + { + noticeSample?.Play(); + }, 1050); + }); + } + } + + private void popIn() + { + this.FadeIn(200); + + foreach (var bar in bars) + { + double delay = Math.Abs(bar.StarRating - 5) * 50; + + bar.Delay(delay) + .ResizeHeightTo(0.1f, 300, Easing.OutExpo); + } + + foreach (var drawable in starContainer) + { + double delay = Math.Abs((drawable.X * 10) - 5) * 50 + 100; + + drawable.Delay(delay) + .ScaleTo(0.8f, 400, Easing.OutElasticQuarter); + } + } + + public void PopOut() + { + foreach (var bar in bars) + { + double delay = Math.Abs(bar.StarRating - 5) * 50; + + bar.Delay(delay) + .ResizeHeightTo(0f, 300, Easing.OutExpo); + } + + foreach (var drawable in starContainer) + { + double delay = Math.Abs((drawable.X * 10) - 5) * 50 + 100; + + drawable.Delay(delay) + .ScaleTo(0f, 400, Easing.OutElasticQuarter); + } + + this.FadeOut(150); + } + + protected override void Update() + { + base.Update(); + + if (!animateGaussianCurve) + return; + + foreach (var bar in bars) + { + float value = gaussianCurve(bar.StarRating, 1f, starRating, stdDev); + + bar.Height = float.Lerp(0.1f, 1f, value * amplitude); + + float targetAlpha = float.Clamp(0.35f + value * 20f, 0.35f, 1); + + bar.Alpha = float.Lerp(targetAlpha, bar.Alpha, (float)Math.Exp(-0.01f * Time.Elapsed)); + } + + foreach (var child in starContainer) + { + float value = gaussianCurve(child.X * 10f, 1f, starRating, stdDev); + + float targetAlpha = float.Clamp(0.35f + value * 20f, 0.35f, 1); + + child.Alpha = float.Lerp(targetAlpha, child.Alpha, (float)Math.Exp(-0.01f * Time.Elapsed)); + } + + static float gaussianCurve(float x, float amplitude, float center, float stdev) + { + float v1 = x - center; + float v2 = (v1 * v1) / (2 * (stdev * stdev)); + return amplitude * MathF.Exp(-v2); + } + + if (Math.Abs(lastTickStdDev - stdDev) <= 0.075) return; + + var tickChannel = tickSample!.GetChannel(); + tickChannel.Frequency.Value = 1 + amplitude * 0.3f; + tickChannel.Volume.Value = 0.5 + amplitude * 0.5; + tickChannel.Play(); + + if (stdDev < 1) + { + var tickFinalChannel = tickFinalSample!.GetChannel(); + tickFinalChannel.Frequency.Value = 1 + amplitude * 0.3f; + tickFinalChannel.Volume.Value = 0.1f + amplitude * 0.4f; + tickFinalChannel.Play(); + } + + lastTickStdDev = stdDev; + } + + private partial class Bar : CircularContainer + { + public required float StarRating; + + public Bar() + { + Masking = true; + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs new file mode 100644 index 0000000000..584dea0a6b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs @@ -0,0 +1,9 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public record UserWithRating(APIUser User, int Rating); +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs new file mode 100644 index 0000000000..b845c00310 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs @@ -0,0 +1,325 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class VsSequence(UserWithRating player, UserWithRating opponent) : CompositeDrawable + { + private Drawable playerBackground = null!; + private Drawable opponentBackground = null!; + private Box flash = null!; + private Drawable playerDisplay = null!; + private Drawable opponentDisplay = null!; + private CoverReveal opponentCoverReveal = null!; + private CoverReveal playerCoverReveal = null!; + private VsText vsText = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -100 }, + Children = + [ + playerBackground = new DelayedLoadWrapper(() => new PlayerCover(player.User) + { + RelativeSizeAxes = Axes.Both, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.5f), Color4.White.Opacity(0.85f)), + Alpha = 0, + AlwaysPresent = true, + }, + opponentBackground = new DelayedLoadWrapper(() => new PlayerCover(opponent.User) + { + RelativeSizeAxes = Axes.Both, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.85f), Color4.White.Opacity(0.5f)), + Alpha = 0, + AlwaysPresent = true, + }, + ], + }, + playerDisplay = new UserDisplay(player, Anchor.BottomLeft) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(70), + Alpha = 0, + AlwaysPresent = true, + }, + opponentDisplay = new UserDisplay(opponent, Anchor.BottomRight) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding(70), + Alpha = 0, + AlwaysPresent = true, + }, + opponentCoverReveal = new CoverReveal(RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(-1, 1), + Alpha = 0, + }, + playerCoverReveal = new CoverReveal(RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + flash = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + vsText = new VsText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ]; + } + + public void Play(ref double delay, out double impactDelay) + { + using (BeginDelayedSequence(delay)) + { + this.FadeInFromZero(500); + + vsText.AnimateEntry(1000, Easing.OutExpo); + vsText.ScaleTo(0.4f, 1300, Easing.OutExpo); + } + + delay += 850; + + impactDelay = delay; + + using (BeginDelayedSequence(delay)) + { + flash.FadeOutFromOne(500, Easing.Out); + + vsText.RevealText(); + + playerCoverReveal.FadeIn(); + opponentCoverReveal.FadeIn(); + + playerCoverReveal.Play(); + opponentCoverReveal.Play(); + + playerBackground + .FadeIn() + .MoveToX(-40) + .MoveToX(40, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.65)) + .Then() + .MoveToX(100, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + opponentBackground + .FadeIn() + .MoveToX(40) + .MoveToX(-40, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.65)) + .Then() + .MoveToX(-100, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + playerDisplay + .FadeIn() + .MoveToX(-400) + .MoveToX(-100, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.75)) + .Then() + .MoveToX(800, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + opponentDisplay + .FadeIn() + .MoveToX(400) + .MoveToX(100, 3000, new CubicBezierEasingFunction(0, 0.6, 0, 0.75)) + .Then() + .MoveToX(-800, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + vsText.Delay(3200) + .ScaleTo(0.25f, 400, Easing.InCubic); + + this.Delay(3200).FadeOut(300).Expire(); + } + + delay += 3350; + } + + private partial class UserDisplay : CompositeDrawable + { + public UserDisplay(UserWithRating user, Anchor contentAnchor) + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = + [ + new CircularContainer + { + Size = new Vector2(96), + Masking = true, + Anchor = contentAnchor, + Origin = contentAnchor, + Child = new DelayedLoadWrapper(() => new DrawableAvatar(user.User) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = contentAnchor, + Origin = contentAnchor, + Padding = new MarginPadding { Vertical = 10 }, + Children = + [ + new OsuSpriteText + { + Text = FormattableString.Invariant($"Rating: {user.Rating:N0}"), + Alpha = 0.8f, + Font = OsuFont.Style.Title.With(size: 26), + Anchor = contentAnchor, + Origin = contentAnchor, + }, + new OsuSpriteText + { + Text = user.User.Username, + Font = OsuFont.Style.Title.With(size: 40, weight: FontWeight.SemiBold), + Anchor = contentAnchor, + Origin = contentAnchor, + }, + ] + } + ] + }; + } + } + + [LongRunningLoad] + public partial class PlayerCover : CompositeDrawable + { + private readonly APIUser user; + + public PlayerCover(APIUser user) + { + this.user = user; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + Masking = true; + + AddInternal(new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(user.CoverUrl), + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeInFromZero(250); + } + } + + private partial class VsText : CompositeDrawable + { + private Sprite vsText = null!; + private LogoAnimation logoAnimation = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = + [ + vsText = new Sprite + { + Texture = textures.Get("Online/RankedPlay/vs"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + }, + logoAnimation = new LogoAnimation + { + Texture = textures.Get("Online/RankedPlay/vs-animation"), + }, + ]; + } + + public void AnimateEntry(double duration, Easing easing) + { + logoAnimation.TransformTo(nameof(logoAnimation.AnimationProgress), 1f, duration, easing); + } + + public void RevealText() + { + vsText.FadeIn(); + logoAnimation.FadeOut(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs new file mode 100644 index 0000000000..122b3ff8dd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class OpponentPickScreen : RankedPlaySubScreen + { + public CardFlow CenterRow { get; private set; } = null!; + + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new RankedPlayStageDisplay(RankedPlayColourScheme.Red) + { + Heading = "Pick Phase", + Caption = "Waiting for your opponent...", + Margin = new MarginPadding { Top = 60 }, + }, + ]; + + 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, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }, + new HandReplayRecorder(playerHand), + new HandReplayPlayer(matchInfo.OpponentId, opponentHand), + ]; + + 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}"); + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + foreach (var card in matchInfo.PlayerCards) + { + playerHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); + }); + } + + foreach (var card in matchInfo.OpponentCards) + { + opponentHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + }); + } + + playerHand.UpdateLayout(stagger: 50); + opponentHand.UpdateLayout(stagger: 50); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.CardPlayed += cardPlayed; + } + + private void cardPlayed(RankedPlayCardWithPlaylistItem item) => Task.Run(async () => + { + if (opponentHand.Cards.FirstOrDefault(it => it.Card.Item.Equals(item)) is { } c) + await c.Card.CardRevealed.ConfigureAwait(false); + + Schedule(() => + { + RankedPlayCard? card; + + if (opponentHand.RemoveCard(item, out card, out var drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + } + else + { + Logger.Log($"Played card {item.Card.ID} was not present in hand.", level: LogLevel.Error); + + card = new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + CenterRow.Add(card); + + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + + card + .MoveTo(new Vector2(0), 600, Easing.OutExpo) + .ScaleTo(CENTERED_CARD_SCALE, 600, Easing.OutExpo) + .RotateTo(0, 400, Easing.OutExpo); + + opponentHand.Contract(); + playerHand.Contract(); + }); + }); + + protected override void Dispose(bool isDisposing) + { + matchInfo.CardPlayed -= cardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs new file mode 100644 index 0000000000..2f59e22b0f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs @@ -0,0 +1,267 @@ +// 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 System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class PickScreen : RankedPlaySubScreen + { + // When the 'time running out' warning sample starts to play (in remaining seconds) + private const int warning_time_threshold = 10; + + public CardFlow CenterRow { get; private set; } = null!; + + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private Sample? cardAddSample; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + private bool timeRunningOutWarningActive; + private Sample? timeRunningOutSample; + private SampleChannel? timeRunningOutSampleChannel; + private Sample? timeUpBuzzerSample; + + private DateTimeOffset stageEndTime; + + /// + /// Whether the local user has played a card themselves. + /// + private bool hasPlayedCard; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new RankedPlayStageDisplay(RankedPlayColourScheme.Blue) + { + Heading = "Pick Phase", + Caption = "It's your turn to play a card!", + Margin = new MarginPadding { Top = 60 }, + }, + ]; + + CenterColumn.Children = + [ + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + 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), + ]; + + 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}"); + + timeRunningOutSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-running-out"); + timeUpBuzzerSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-up"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.CardPlayed += cardPlayed; + + Client.CountdownStarted += onCountdownStarted; + Client.CountdownStopped += onCountdownStopped; + + if (Client.Room != null) + { + foreach (var countdown in Client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + TimeSpan remainingTime = stageEndTime - DateTimeOffset.Now; + + if (timeRunningOutWarningActive && remainingTime.TotalSeconds < warning_time_threshold) + { + timeRunningOutSampleChannel ??= timeRunningOutSample?.GetChannel(); + + if (timeRunningOutSampleChannel == null || timeRunningOutSampleChannel.Playing) + return; + + timeRunningOutSampleChannel.ManualFree = true; + timeRunningOutSampleChannel.Looping = true; + timeRunningOutSampleChannel.Play(); + } + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + int delay = 0; + + foreach (var item in matchInfo.PlayerCards) + { + if ((previous as DiscardScreen)?.CenterRow.RemoveCard(item, out var card, out var drawQuad) == true) + { + playerHand.AddCard(card, c => + { + c.MatchScreenSpaceDrawQuad(drawQuad, playerHand); + }); + } + else + { + playerHand.AddCard(item, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); + }); + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, 50 * delay); + delay++; + } + } + + foreach (var item in matchInfo.OpponentCards) + { + opponentHand.AddCard(item, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + }); + } + + playerHand.UpdateLayout(stagger: 50); + opponentHand.UpdateLayout(stagger: 50); + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown stageCountdown) + return; + + stageEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + timeRunningOutWarningActive = stageCountdown.Stage == RankedPlayStage.CardPlay; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown stageCountdown) + return; + + timeRunningOutSampleChannel?.Stop(); + + stageEndTime = DateTimeOffset.Now; + timeRunningOutWarningActive = false; + + if (stageCountdown.Stage == RankedPlayStage.CardPlay && !hasPlayedCard) + timeUpBuzzerSample?.Play(); + }); + + private void onPlayButtonClicked() + { + var selection = playerHand.Selection.SingleOrDefault(); + + if (selection != null) + { + hasPlayedCard = true; + playerHand.SelectionMode = HandSelectionMode.Disabled; + + Client.PlayCard(selection.Card).FireAndForget(); + } + + playerHand.PlayCardAction = null; + } + + private void cardPlayed(RankedPlayCardWithPlaylistItem item) + { + RankedPlayCard? card; + + if (playerHand.RemoveCard(item, out card, out var drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + } + else + { + Logger.Log($"Played card {item.Card.ID} was not present in hand.", level: LogLevel.Error); + + card = new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + CenterRow.Add(card); + + card + .MoveTo(new Vector2(0), 600, Easing.OutExpo) + .ScaleTo(CENTERED_CARD_SCALE, 600, Easing.OutExpo) + .RotateTo(0, 400, Easing.OutExpo); + + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + + opponentHand.Contract(); + playerHand.Contract(); + + playerHand.SelectionMode = HandSelectionMode.Disabled; + } + + protected override void Dispose(bool isDisposing) + { + timeRunningOutSampleChannel?.Stop(); + timeRunningOutSampleChannel?.Dispose(); + + matchInfo.CardPlayed -= cardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs new file mode 100644 index 0000000000..c6316e1fb1 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Runtime.InteropServices; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shaders.Types; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayBackground : CompositeDrawable, IBufferedDrawable + { + public float GridSize = 10; + + public IShader? TextureShader { get; private set; } + public Color4 BackgroundColour => Color4.Black; + public DrawColourInfo? FrameBufferDrawColour => null; + public Vector2 FrameBufferScale => new Vector2(0.1f); + + public Color4 GradientOutside = Color4Extensions.FromHex("AC6D97"); + public Color4 GradientInside = Color4Extensions.FromHex("544483"); + public Color4 DotsColour = Color4Extensions.FromHex("6b2980"); + + public RankedPlayBackground() + { + InternalChildren = + [ + new Triangles + { + RelativeSizeAxes = Axes.Both, + }, + ]; + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"RankedPlayBackground"); + } + + private readonly BufferedDrawNodeSharedData sharedData = new BufferedDrawNodeSharedData(); + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new RankedPlayBackgroundDrawNode(this, sharedData); + + protected override void Dispose(bool isDisposing) + { + sharedData.Dispose(); + + base.Dispose(isDisposing); + } + + private class RankedPlayBackgroundDrawNode : BufferedDrawNode, ICompositeDrawNode + { + protected new RankedPlayBackground Source => (RankedPlayBackground)base.Source; + + protected new CompositeDrawableDrawNode Child => (CompositeDrawableDrawNode)base.Child; + + public RankedPlayBackgroundDrawNode(RankedPlayBackground source, BufferedDrawNodeSharedData sharedData) + : base(source, new CompositeDrawableDrawNode(source), sharedData) + { + } + + private Vector2 drawSize; + private float time; + private float gridSize; + private Color4 gradientOutside; + private Color4 gradientInside; + private Color4 dotsColour; + + private IUniformBuffer? shaderParameterBuffer; + + public override void ApplyState() + { + base.ApplyState(); + + time = (float)(Source.Time.Current / 1000); + drawSize = Source.DrawSize; + gridSize = Source.GridSize; + gradientOutside = Source.GradientOutside; + gradientInside = Source.GradientInside; + dotsColour = Source.DotsColour; + } + + protected override void BindUniformResources(IShader shader, IRenderer renderer) + { + shaderParameterBuffer ??= renderer.CreateUniformBuffer(); + + shaderParameterBuffer.Data = new RankedPlayBackgroundParameters + { + DrawSize = drawSize, + Time = time, + GridSize = gridSize, + GradientOutside = new Vector4(gradientOutside.R, gradientOutside.G, gradientOutside.B, gradientOutside.A), + GradientInside = new Vector4(gradientInside.R, gradientInside.G, gradientInside.B, gradientInside.A), + DotsColour = new Vector4(dotsColour.R, dotsColour.G, dotsColour.B, dotsColour.A), + }; + + shader.BindUniformBlock("m_RankedPlayBackgroundParameters", shaderParameterBuffer); + } + + public List? Children + { + get => Child.Children; + set => Child.Children = value; + } + + public bool AddChildDrawNodes => RequiresRedraw; + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct RankedPlayBackgroundParameters + { + public UniformVector2 DrawSize; + public UniformFloat Time; + public UniformFloat GridSize; + public UniformVector4 GradientOutside; + public UniformVector4 GradientInside; + public UniformVector4 DotsColour; + } + } + + public partial class Triangles : CompositeDrawable + { + private Texture triangleTexture = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + triangleTexture = textures.Get("Online/RankedPlay/triangle"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + for (int i = 0; i < 20; i++) + { + AddInternal(new Triangle + { + Texture = triangleTexture, + RelativePositionAxes = Axes.Both, + X = RNG.NextSingle(), + Y = -0.2f + RNG.NextSingle() * 1.4f, + Origin = Anchor.Centre, + Rotation = RNG.NextSingle() * 360, + AngularVelocity = RNG.NextSingle() - 0.75f, + Size = new Vector2(100 + RNG.NextSingle() * 1000), + MovementSpeed = 0.25f + RNG.NextSingle() * 0.75f, + Alpha = 0.5f + RNG.NextSingle() * 0.5f, + }); + } + } + + public float ParticleVelocity = 1; + + protected override void Update() + { + base.Update(); + + if (DrawHeight <= 0) + return; + + float baseVelocity = 0.03f * ParticleVelocity / DrawHeight; + float elapsed = (float)Time.Elapsed; + + foreach (var c in InternalChildren) + { + var triangle = (Triangle)c; + + triangle.Y -= baseVelocity * elapsed * triangle.MovementSpeed; + + triangle.Rotation += triangle.AngularVelocity * elapsed * 0.02f; + + // wrap vertically + if (triangle.Y < -0.2f) + { + triangle.X = RNG.NextSingle(); + triangle.Y = 1.2f; + triangle.Alpha = 0.5f + RNG.NextSingle() * 0.5f; + } + else if (triangle.Y > 1.2f) + { + triangle.X = RNG.NextSingle(); + triangle.Y = -0.2f; + triangle.Alpha = 0.5f + RNG.NextSingle() * 0.5f; + } + } + } + + private partial class Triangle : Sprite + { + public float MovementSpeed = 1; + public float AngularVelocity; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs new file mode 100644 index 0000000000..72224b876e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Beatmaps; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayBackgroundScreen : BackgroundScreen + { + public RankedPlayBackground Background { get; } + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + public Bindable ShowBeatmapBackground { get; } = new BindableBool(); + + public RankedPlayBackgroundScreen() + { + InternalChild = Background = new RankedPlayBackground + { + RelativeSizeAxes = Axes.Both, + GradientOutside = Color4Extensions.FromHex("716BE0"), + GradientInside = Color4Extensions.FromHex("#71308F"), + DotsColour = Color4Extensions.FromHex("#CC46F6").Opacity(0.5f), + }; + } + + private CancellationTokenSource? pendingBackgroundLoad; + private BeatmapBackground? currentBackground; + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateBackground()); + ShowBeatmapBackground.BindValueChanged(_ => updateBackground()); + updateBackground(); + } + + private void updateBackground() + { + pendingBackgroundLoad?.Cancel(); + + if (beatmap.Value == null || !ShowBeatmapBackground.Value) + { + currentBackground?.PopOut().Expire(); + currentBackground = null; + return; + } + + pendingBackgroundLoad = new CancellationTokenSource(); + + LoadComponentAsync(new BeatmapBackground(beatmap.Value), background => + { + currentBackground?.PopOut().Expire(); + + AddInternal(background); + currentBackground = background; + + background.PopIn(); + }, pendingBackgroundLoad.Token); + } + + [LongRunningLoad] + private partial class BeatmapBackground(WorkingBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = new BufferedContainer(cachedFrameBuffer: true) + { + RelativeSizeAxes = Axes.Both, + FrameBufferScale = new Vector2(0.15f), + GrayscaleStrength = 0.3f, + BlurSigma = new Vector2(5), + Colour = Color4Extensions.FromHex("#cccccc"), + Child = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = beatmap.GetBackground(), + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + public void PopIn() => this.FadeOut() + .FadeTo(0.4f, 300) + .ScaleTo(1.2f) + .ScaleTo(1f, 600, Easing.OutExpo); + + public TransformSequence PopOut() => + this.FadeOut(300).ScaleTo(1.1f, 600, Easing.OutExpo); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs new file mode 100644 index 0000000000..71ef9ee9db --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs @@ -0,0 +1,30 @@ +// 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.Bindables; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public class RankedPlayCardWithPlaylistItem : IEquatable + { + public readonly Bindable PlaylistItem = new Bindable(); + public readonly RankedPlayCardItem Card; + + public RankedPlayCardWithPlaylistItem(RankedPlayCardItem card) + { + Card = card; + } + + public bool Equals(RankedPlayCardWithPlaylistItem? other) + => other != null && Card.Equals(other.Card); + + public override bool Equals(object? obj) + => obj is RankedPlayCardWithPlaylistItem other && Equals(other); + + public override int GetHashCode() + => Card.GetHashCode(); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs new file mode 100644 index 0000000000..89b95d0611 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public class RankedPlayColourScheme + { + public required Color4 Primary; + public required Color4 PrimaryDarker; + public required Color4 PrimaryDarkest; + public required Color4 Surface; + public required Color4 SurfaceBorder; + + public static RankedPlayColourScheme Blue => new RankedPlayColourScheme + { + Primary = Color4Extensions.FromHex("5EBFFF"), + PrimaryDarker = Color4Extensions.FromHex("4382FF"), + PrimaryDarkest = Color4Extensions.FromHex("5C55FF"), + Surface = Color4Extensions.FromHex("33303D"), + SurfaceBorder = Color4Extensions.FromHex("514c5e"), + }; + + public static RankedPlayColourScheme Red => new RankedPlayColourScheme + { + Primary = Color4Extensions.FromHex("FF8198"), + PrimaryDarker = Color4Extensions.FromHex("F94D92"), + PrimaryDarkest = Color4Extensions.FromHex("B6104D"), + Surface = Color4Extensions.FromHex("242023"), + SurfaceBorder = Color4Extensions.FromHex("403b3f"), + }; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs new file mode 100644 index 0000000000..657fbb1380 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs @@ -0,0 +1,176 @@ +// 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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayMatchInfo : Component + { + /// + /// Cards belonging to the player. + /// + public IReadOnlyList PlayerCards => playerCards; + + /// + /// Cards belonging to the opponent. + /// + public IReadOnlyList OpponentCards => opponentCards; + + /// + /// The last card that was played. + /// + public RankedPlayCardWithPlaylistItem? LastPlayedCard { get; private set; } + + /// + /// The current room stage. + /// + public IBindable Stage => stage; + + /// + /// Fired when a card gets added to the player's hand. + /// + public event Action? PlayerCardAdded; + + /// + /// Fired when a card gets removed from the player's hand, i.e. by being discarded. + /// + public event Action? PlayerCardRemoved; + + /// + /// Fired when a card gets added to the opponent's hand. + /// + public event Action? OpponentCardAdded; + + /// + /// Fired when a card gets removed from the player's hand, i.e. by being discarded. + /// + public event Action? OpponentCardRemoved; + + /// + /// Fired when the active player plays a card. + /// + public event Action? CardPlayed; + + /// + /// The player's health + /// + public readonly BindableInt PlayerHealth = new BindableInt { MinValue = 0, MaxValue = 1_000_000, Value = 1_000_000 }; + + /// + /// The opponent's health + /// + public readonly BindableInt OpponentHealth = new BindableInt { MinValue = 0, MaxValue = 1_000_000, Value = 1_000_000 }; + + public RankedPlayRoomState RoomState { get; private set; } = null!; + + public bool IsOwnTurn => RoomState.ActiveUserId == client.LocalUser?.UserID; + + public int CurrentRound => RoomState.CurrentRound; + + public int OpponentId => RoomState.Users.Keys.Single(u => u != client.LocalUser?.UserID); + + private readonly List playerCards = new List(); + private readonly List opponentCards = new List(); + private readonly Bindable stage = new Bindable(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private APIUser player = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + player = client.LocalUser!.User!; + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + client.RankedPlayCardAdded += onCardAdded; + client.RankedPlayCardRemoved += onCardRemoved; + client.RankedPlayCardPlayed += onCardPlayed; + + var roomState = (RankedPlayRoomState)client.Room!.MatchState!; + + onMatchRoomStateChanged(roomState); + + foreach (var (userId, user) in roomState.Users) + { + foreach (var card in user.Hand) + { + onCardAdded(userId, client.GetCardWithPlaylistItem(card)); + } + } + } + + private void onMatchRoomStateChanged(MatchRoomState state) + { + if (state is not RankedPlayRoomState roomState) + return; + + RoomState = roomState; + + stage.Value = roomState.Stage; + + foreach (var (userId, userInfo) in roomState.Users) + { + if (userId == player.Id) + PlayerHealth.Value = userInfo.Life; + else + OpponentHealth.Value = userInfo.Life; + } + } + + private void onCardAdded(int userId, RankedPlayCardWithPlaylistItem item) + { + if (userId == player.Id) + { + playerCards.Add(item); + PlayerCardAdded?.Invoke(item); + } + else + { + opponentCards.Add(item); + OpponentCardAdded?.Invoke(item); + } + } + + private void onCardRemoved(int userId, RankedPlayCardWithPlaylistItem item) + { + if (userId == player.Id) + { + playerCards.Remove(item); + PlayerCardRemoved?.Invoke(item); + } + else + { + opponentCards.Remove(item); + OpponentCardRemoved?.Invoke(item); + } + } + + private void onCardPlayed(RankedPlayCardWithPlaylistItem item) + { + LastPlayedCard = item; + CardPlayed?.Invoke(item); + } + + protected override void Dispose(bool isDisposing) + { + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + client.RankedPlayCardAdded -= onCardAdded; + client.RankedPlayCardRemoved -= onCardRemoved; + client.RankedPlayCardPlayed -= onCardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs new file mode 100644 index 0000000000..c61a8e1fc5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs @@ -0,0 +1,535 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.Cursor; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Volume; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + [Cached] + public partial class RankedPlayScreen : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap + { + protected override bool InitialBackButtonVisibility => false; + + public override bool HideOverlaysOnEnter => true; + + public RankedPlaySubScreen? ActiveSubScreen { get; private set; } + + protected override BackgroundScreen CreateBackground() => new RankedPlayBackgroundScreen + { + ShowBeatmapBackground = { BindTarget = showBeatmapBackground } + }; + + public override float BackgroundParallaxAmount => 0; + + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] + private QueueController? controller { get; set; } + + private readonly MultiplayerRoom room; + private readonly Container screenContainer; + private readonly MatchmakingChatDisplay chat; + + private IBindable stage = null!; + + private Sample? sampleStart; + private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + + private readonly Bindable cornerPieceVisibility = new Bindable(); + private readonly Bindable showBeatmapBackground = new Bindable(); + + [Cached] + private readonly RankedPlayMatchInfo matchInfo; + + [Cached] + private readonly CardDetailsOverlayContainer overlayContainer; + + [Cached] + private readonly SongPreviewParticleContainer particleContainer; + + public RankedPlayScreen(MultiplayerRoom room) + { + this.room = room; + + InternalChildren = new Drawable[] + { + matchInfo = new RankedPlayMatchInfo(), + beatmapAvailabilityTracker, + new GlobalScrollAdjustsVolume(), + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + screenContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + chat = new MatchmakingChatDisplay(new Room(room)) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = new Vector2(320, 160), + Margin = new MarginPadding + { + Bottom = 10, + Right = 10 + }, + Alpha = 0, + }, + new HamburgerMenu + { + Size = new Vector2(56), + } + } + } + }, + overlayContainer = new CardDetailsOverlayContainer(), + particleContainer = new SongPreviewParticleContainer(), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + stage = matchInfo.Stage.GetBoundCopy(); + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.UserStateChanged += onUserStateChanged; + client.SettingsChanged += onSettingsChanged; + client.LoadRequested += onLoadRequested; + + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + + int localUserId = api.LocalUser.Value.OnlineID; + int opponentUserId = ((RankedPlayRoomState)client.Room!.MatchState!).Users.Keys.Single(it => it != localUserId); + + AddRangeInternal([ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + State = { BindTarget = cornerPieceVisibility }, + Child = new RankedPlayUserDisplay(localUserId, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Health = { BindTarget = matchInfo.PlayerHealth } + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.TopRight) + { + State = { BindTarget = cornerPieceVisibility }, + Child = new RankedPlayUserDisplay(opponentUserId, Anchor.TopRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Health = { BindTarget = matchInfo.OpponentHealth } + } + }, + ]); + + cornerPieceVisibility.BindValueChanged(e => + { + if (e.NewValue == Visibility.Visible) + chat.Appear(); + else + chat.Disappear(); + }); + + stage.BindValueChanged(e => onStageChanged(e.NewValue)); + } + + public void ShowScreen(RankedPlaySubScreen screen) + { + if (screen == ActiveSubScreen) + return; + + LoadComponent(screen); + + var previousScreen = ActiveSubScreen; + + screenContainer.Add(ActiveSubScreen = screen); + screen.OnLoadComplete += _ => + { + previousScreen?.OnExiting(screen); + screen.OnEntering(previousScreen); + previousScreen?.Expire(); + + if (previousScreen != null) + cornerPieceVisibility.UnbindFrom(previousScreen.CornerPieceVisibility); + + cornerPieceVisibility.BindTo(screen.CornerPieceVisibility); + showBeatmapBackground.Value = screen.ShowBeatmapBackground; + }; + } + + private void onRoomUpdated() + { + if (this.IsCurrentScreen() && client.Room == null) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + exitConfirmed = true; + this.Exit(); + } + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + if (user.Equals(client.LocalUser) && state == MultiplayerUserState.Idle) + this.MakeCurrent(); + } + + private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() => + { + checkForAutomaticDownload(); + updateGameplayState(); + }); + + private void onLoadRequested() => Scheduler.Add(() => + { + updateGameplayState(); + + if (Beatmap.IsDefault) + { + Logger.Log("Aborting gameplay start - beatmap not downloaded."); + return; + } + + sampleStart?.Play(); + + this.Push(new MultiplayerPlayerLoader(() => new ScreenGameplay(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray()))); + }); + + private void onStageChanged(RankedPlayStage stage) + { + switch (stage) + { + case RankedPlayStage.RoundWarmup when matchInfo.CurrentRound == 1: + ShowScreen(new IntroScreen()); + break; + + case RankedPlayStage.CardDiscard: + ShowScreen(new DiscardScreen()); + break; + + case RankedPlayStage.FinishCardDiscard: + (ActiveSubScreen as DiscardScreen)?.PresentRemainingCards(); + break; + + case RankedPlayStage.CardPlay: + ShowScreen(matchInfo.IsOwnTurn ? new PickScreen() : new OpponentPickScreen()); + break; + + case RankedPlayStage.FinishCardPlay: + Debug.Assert(ActiveSubScreen is PickScreen || ActiveSubScreen is OpponentPickScreen); + break; + + case RankedPlayStage.GameplayWarmup: + ShowScreen(new GameplayWarmupScreen()); + break; + + case RankedPlayStage.Gameplay: + ShowScreen(new GameplayScreen()); + break; + + case RankedPlayStage.Results: + ShowScreen(new ResultsScreen()); + break; + + case RankedPlayStage.Ended: + ShowScreen(new EndedScreen + { + ExitRequested = retry => + { + retryRequested = retry; + exitConfirmed = true; + + if (this.IsCurrentScreen()) + this.Exit(); + } + }); + break; + } + } + + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) => Scheduler.Add(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.NotDownloaded: + case DownloadState.LocallyAvailable: + updateGameplayState(); + break; + } + }); + + private void updateGameplayState() + { + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + + if (item.Expired) + return; + + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + + if (localBeatmap != null) + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + + // Notify the server that the beatmap has been set and that we are ready to start gameplay. + if (client.LocalUser!.State == MultiplayerUserState.Idle) + client.ChangeState(MultiplayerUserState.Ready).FireAndForget(); + } + else + { + // Notify the server that we don't have the beatmap. + if (client.LocalUser!.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + client.ChangeBeatmapAvailability(beatmapAvailabilityTracker.Availability.Value).FireAndForget(); + } + + private void checkForAutomaticDownload() + { + if (client.Room == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + + downloadCheckCancellation?.Cancel(); + + if (beatmapManager.IsAvailableLocally(new APIBeatmap { OnlineID = item.BeatmapID })) + return; + + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. + // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. + beatmapLookupCache + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .ContinueWith(resolved => Schedule(() => + { + APIBeatmapSet? beatmapSet = resolved.GetResultSafely()?.BeatmapSet; + + if (beatmapSet == null) + return; + + beatmapDownloader.Download(beatmapSet, config.Get(OsuSetting.PreferNoVideo)); + })); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + endHandlingTrack(); + + base.OnSuspending(e); + } + + private bool exitConfirmed; + private bool retryRequested; + + public override bool OnExiting(ScreenExitEvent e) + { + if (exitConfirmed || ActiveSubScreen is EndedScreen) + { + if (base.OnExiting(e)) + { + exitConfirmed = false; + return true; + } + + endHandlingTrack(); + + client.LeaveRoom().FireAndForget(); + + if (retryRequested) + controller?.RejoinQueue(); + + return false; + } + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + beginHandlingTrack(); + + if (e.Last is not MultiplayerPlayerLoader playerLoader) + return; + + if (!playerLoader.GameplayPassed) + { + client.AbortGameplay().FireAndForget(); + return; + } + + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + // Do nothing to prevent the user from potentially being kicked out + // of gameplay due to the screen performer's internal processes. + } + + protected override void Dispose(bool isDisposing) + { + client.RoomUpdated -= onRoomUpdated; + client.UserStateChanged -= onUserStateChanged; + client.SettingsChanged -= onSettingsChanged; + client.LoadRequested -= onLoadRequested; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs new file mode 100644 index 0000000000..b19340107f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public abstract partial class RankedPlaySubScreen : Container + { + public const float CENTERED_CARD_SCALE = 1.2f; + + public readonly Bindable CornerPieceVisibility = new Bindable(Visibility.Visible); + + public virtual bool ShowBeatmapBackground => false; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected MultiplayerClient Client => client; + + protected override Container Content { get; } + + protected readonly Container CenterColumn; + + protected readonly FillFlowContainer ButtonsContainer; + + protected RankedPlaySubScreen() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + CenterColumn = new Container + { + Name = "Center Column", + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(20), + }, + Content = new Container + { + Name = "Content", + RelativeSizeAxes = Axes.Both, + }, + ButtonsContainer = new FillFlowContainer + { + Name = "Buttons", + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + X = 30, + Y = -110, + Direction = FillDirection.Vertical, + Spacing = new Vector2(8) + }, + ]; + } + + protected override void Update() + { + base.Update(); + + CenterColumn.Width = DrawWidth - RankedPlayCornerPiece.WidthFor(DrawWidth) * 2; + } + + public virtual void OnEntering(RankedPlaySubScreen? previous) + { + } + + public virtual void OnExiting(RankedPlaySubScreen? next) + { + Hide(); + } + + protected static string FormatRoundIndex(int roundNumber) + { + return roundNumber >= 10 ? roundNumber.Ordinalize(CultureInfo.InvariantCulture) : roundNumber.ToOrdinalWords(CultureInfo.InvariantCulture); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs new file mode 100644 index 0000000000..e4c69f9ee7 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class PanelScaffold : Container + { + private const float corner_radius = 6; + private const float border_thickness = 2; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + public readonly ScreenBottomOrnament BottomOrnament = new ScreenBottomOrnament(); + + private BufferedContainer background = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = -30 }, + Child = background = new BufferedContainer(cachedFrameBuffer: false) + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 30 }, + BackgroundColour = Color4Extensions.FromHex("222228").Opacity(0), + Alpha = 0.7f, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius, + BorderThickness = border_thickness, + BorderColour = new ColourInfo + { + TopLeft = RankedPlayColourScheme.Blue.PrimaryDarkest.Opacity(0.5f), + BottomLeft = RankedPlayColourScheme.Blue.Primary.Opacity(0.75f), + TopRight = RankedPlayColourScheme.Red.PrimaryDarkest.Opacity(0.5f), + BottomRight = RankedPlayColourScheme.Red.Primary.Opacity(0.75f), + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("222228"), + }, + }, + } + }, + Content.With(static d => + { + d.Masking = true; + d.CornerRadius = corner_radius; + }), + BottomOrnament.With(static d => + { + d.Anchor = Anchor.BottomCentre; + d.Origin = Anchor.Centre; + d.Y -= border_thickness / 2; + }), + ]; + + background.Add(BottomOrnament.Background.CreateProxy()); + } + } + + public partial class ScreenBottomOrnament : Container + { + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both, }; + + public Drawable Background => background; + + private readonly Container background = new Container { RelativeSizeAxes = Axes.Both }; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = + [ + background.WithChildren([ + new NineSliceSprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("Online/RankedPlay/damage-display-background"), + TextureInsetRelativeAxes = Axes.None, + TextureInset = new MarginPadding { Horizontal = 30 }, + Colour = Color4Extensions.FromHex("222228"), + }, + new NineSliceSprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("Online/RankedPlay/damage-display-border"), + TextureInsetRelativeAxes = Axes.None, + TextureInset = new MarginPadding { Horizontal = 30 }, + Alpha = 0.25f, + Colour = Color4Extensions.FromHex("ddddff") + }, + ]), + Content, + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs new file mode 100644 index 0000000000..7aee36358f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class ScoreBar(RankedPlayColourScheme colours) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 6; + BorderThickness = 2; + BorderColour = colours.PrimaryDarkest.Darken(0.35f); + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Height = 1 / 3f, + Colour = ColourInfo.GradientVertical(colours.Primary, colours.PrimaryDarker) + }, + new Box + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + Height = 2f / 3f, + Y = 1f / 3f, + Colour = ColourInfo.GradientVertical(colours.PrimaryDarker, colours.PrimaryDarkest) + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(3), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 3, + Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.White.Opacity(0)), + BorderThickness = 3, + BorderColour = ColourInfo.GradientVertical(Colour4.White, Colour4.White.Opacity(0)), + Alpha = 0.25f, + Blending = BlendingParameters.Additive, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + }, + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs new file mode 100644 index 0000000000..f8a55e8778 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class ScoreDetails(ScoreInfo score, RankedPlayColourScheme colours) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30), + Children = + [ + new ScoreStatisticsDisplay(score, colours) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + ], + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Content = new Drawable[][] + { + [ + new ScoreRankDisplay(score) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(20), + Children = + [ + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new OsuSpriteText + { + Text = "Accuracy", + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = score.DisplayAccuracy, + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold) + }, + ] + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new OsuSpriteText + { + Text = "Combo", + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = $"{score.MaxCombo}x", + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold) + }, + ] + } + ] + } + ] + } + } + ] + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs new file mode 100644 index 0000000000..b3981f0581 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + private partial class ScoreRankDisplay : CompositeDrawable + { + private readonly ScoreInfo score; + + public ScoreRankDisplay(ScoreInfo score) + { + this.score = score; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(SkinManager skinManager) + { + InternalChild = new Sprite + { + Scale = new Vector2(0.5f), + Texture = skinManager.DefaultClassicSkin.GetTexture(DrawableRank.GetLegacyRankTextureName(score.Rank)) + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs new file mode 100644 index 0000000000..217984beff --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + private partial class ScoreStatisticsDisplay : CompositeDrawable + { + private readonly ScoreInfo score; + private readonly RankedPlayColourScheme colours; + + private FillFlowContainer statisticsFlow = null!; + + public ScoreStatisticsDisplay(ScoreInfo score, RankedPlayColourScheme colours) + { + this.score = score; + this.colours = colours; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + statisticsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10, 20), + Children = score.GetStatisticsForDisplay().Select(it => new BeatmapTitleWedge.StatisticDifficulty + { + Width = 80, + Value = new BeatmapTitleWedge.StatisticDifficulty.Data(it.DisplayName, it.Count, it.Count, it.MaxCount ?? it.Count), + AccentColour = colours.PrimaryDarker, + }).ToArray(), + } + } + }; + } + + protected override void Update() + { + base.Update(); + + int statisticsPerRow = (statisticsFlow.Count + 1) / 2; + float statisticWidth = (DrawWidth - (statisticsPerRow - 1) * statisticsFlow.Spacing.X) / statisticsPerRow; + foreach (var statistic in statisticsFlow) + statistic.Width = statisticWidth; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs new file mode 100644 index 0000000000..872920272a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs @@ -0,0 +1,606 @@ +// 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 System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Logging; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen : RankedPlaySubScreen + { + public override bool ShowBeatmapBackground => true; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private IBindable globalRuleset { get; set; } = null!; + + private LoadingSpinner loadingSpinner = null!; + + [BackgroundDependencyLoader] + private void load() + { + CornerPieceVisibility.Value = Visibility.Hidden; + + InternalChildren = new Drawable[] + { + loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadingSpinner.Show(); + + queryScores().FireAndForget(); + } + + private async Task queryScores() + { + try + { + if (client.Room == null) + return; + + Task beatmapTask = beatmapLookupCache.GetBeatmapAsync(client.Room.CurrentPlaylistItem.BeatmapID); + TaskCompletionSource> scoreTask = new TaskCompletionSource>(); + + var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId); + request.Success += req => scoreTask.SetResult(req.Scores); + request.Failure += scoreTask.SetException; + api.Queue(request); + + await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false); + + APIBeatmap? apiBeatmap = beatmapTask.GetResultSafely(); + List apiScores = scoreTask.Task.GetResultSafely(); + + if (apiBeatmap == null) + return; + + // Reference: PlaylistItemResultsScreen + setScores(apiScores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(apiBeatmap.Difficulty), + Metadata = + { + Artist = apiBeatmap.Metadata.Artist, + Title = apiBeatmap.Metadata.Title, + Author = new RealmUser + { + Username = apiBeatmap.Metadata.Author.Username, + OnlineID = apiBeatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = apiBeatmap.DifficultyName, + StarRating = apiBeatmap.StarRating, + Length = apiBeatmap.Length, + BPM = apiBeatmap.BPM + })).ToArray()); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load scores for playlist item."); + throw; + } + finally + { + Scheduler.Add(() => loadingSpinner.Hide()); + } + } + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private void setScores(ScoreInfo[] scores) => Scheduler.Add(() => + { + int playerId = api.LocalUser.Value.OnlineID; + int opponentId = matchInfo.RoomState.Users.Keys.Single(it => it != playerId); + + ScoreInfo playerScore = scores.SingleOrDefault(s => s.UserID == playerId) ?? new ScoreInfo + { + Rank = ScoreRank.F, + Ruleset = globalRuleset.Value, + User = new APIUser { Id = playerId } + }; + + ScoreInfo opponentScore = scores.SingleOrDefault(s => s.UserID == opponentId) ?? new ScoreInfo + { + Rank = ScoreRank.F, + Ruleset = globalRuleset.Value, + User = new APIUser { Id = opponentId } + }; + + AddInternal(new ResultScreenContent + { + PlayerScore = playerScore, + OpponentScore = opponentScore, + PlayerDamageInfo = matchInfo.RoomState.Users[playerId].DamageInfo!, + OpponentDamageInfo = matchInfo.RoomState.Users[opponentId].DamageInfo!, + }); + }); + + private partial class ResultScreenContent : CompositeDrawable + { + public required ScoreInfo PlayerScore { get; init; } + public required ScoreInfo OpponentScore { get; init; } + public required RankedPlayDamageInfo PlayerDamageInfo { get; init; } + public required RankedPlayDamageInfo OpponentDamageInfo { get; init; } + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + [Resolved] + private OsuColour colour { get; set; } = null!; + + private static Vector2 cardSize => new Vector2(950, 550); + + private readonly Bindable cornerPieceVisibility = new Bindable(); + private readonly Bindable scoreBarProgress = new Bindable(); + + private PanelScaffold panelScaffold = null!; + private Box flash = null!; + private ScoreDetails playerScoreDetails = null!; + private ScoreDetails opponentScoreDetails = null!; + private RankedPlayScoreCounter playerScoreCounter = null!; + private RankedPlayScoreCounter opponentScoreCounter = null!; + private RankedPlayScoreCounter damageCounter = null!; + private OsuSpriteText flyingDamageText = null!; + private ScoreBar playerScoreBar = null!; + private ScoreBar opponentScoreBar = null!; + private OsuSpriteText roundNumber = null!; + private RankedPlayUserDisplay playerUserDisplay = null!; + private RankedPlayUserDisplay opponentUserDisplay = null!; + + private RankedPlayDamageInfo losingDamageInfo = null!; + + [BackgroundDependencyLoader] + private void load() + { + // this works under the assumption that only one player can receive damage each round + losingDamageInfo = matchInfo.RoomState.Users + .Select(it => it.Value.DamageInfo) + .OfType() + .MaxBy(it => it.Damage)!; + + RelativeSizeAxes = Axes.Both; + + AddInternal(panelScaffold = new PanelScaffold + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { BindTarget = cornerPieceVisibility }, + Child = playerUserDisplay = new RankedPlayUserDisplay(PlayerScore.UserID, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Health = { Value = PlayerDamageInfo.OldLife } + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.BottomRight) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + State = { BindTarget = cornerPieceVisibility }, + Child = opponentUserDisplay = new RankedPlayUserDisplay(OpponentScore.UserID, Anchor.BottomRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Health = { Value = OpponentDamageInfo.OldLife } + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 110, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Padding = new MarginPadding { Bottom = 30 }, + Child = roundNumber = new OsuSpriteText + { + Text = $"Round {matchInfo.CurrentRound}", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 36, weight: FontWeight.Bold, typeface: Typeface.TorusAlternate), + Alpha = 0, + }, + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = cardSize, + Padding = new MarginPadding { Bottom = 110, Top = 60, Horizontal = 60 }, + ColumnDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.Absolute, 40), + new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 40), + new Dimension(), + ], + Content = new Drawable?[][] + { + [ + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new Drawable[][] + { + [ + playerScoreDetails = new ScoreDetails(PlayerScore, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + ], + [ + playerScoreCounter = new RankedPlayScoreCounter(numDigits(PlayerScore.TotalScore)) + { + Font = OsuFont.GetFont(size: 60, fixedWidth: true), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-4), + Alpha = 0, + AlwaysPresent = true, + } + ] + } + }, + null, + playerScoreBar = new ScoreBar(RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Height = 0.05f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + null, + opponentScoreBar = new ScoreBar(RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Height = 0.05f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new Drawable[][] + { + [ + opponentScoreDetails = new ScoreDetails(OpponentScore, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + ], + [ + opponentScoreCounter = new RankedPlayScoreCounter(numDigits(OpponentScore.TotalScore)) + { + Font = OsuFont.GetFont(size: 60, fixedWidth: true), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-4), + Alpha = 0, + AlwaysPresent = true, + } + ] + } + }, + ] + } + }, + flash = new Box + { + RelativeSizeAxes = Axes.Both, + }, + ], + BottomOrnament = + { + Size = new Vector2(200, 60), + Alpha = 0, + Children = + [ + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + damageCounter = new RankedPlayScoreCounter(numDigits(losingDamageInfo.Damage)) + { + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-2), + }, + flyingDamageText = new OsuSpriteText + { + Text = FormattableString.Invariant($"{losingDamageInfo.Damage:N0}"), + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BypassAutoSizeAxes = Axes.Both, + Alpha = 0, + }, + new OsuSpriteText + { + BypassAutoSizeAxes = Axes.Both, + Text = $"{matchInfo.RoomState.DamageMultiplier.ToStandardFormattedString(maxDecimalDigits: 1)}x", + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 42), + Rotation = 30, + Alpha = 0, + Colour = colour.RedLight + }, + ] + }, + new OsuSpriteText + { + Text = Precision.AlmostEquals(matchInfo.RoomState.DamageMultiplier, 1) + ? "Damage" + : $"Damage {matchInfo.RoomState.DamageMultiplier.ToStandardFormattedString(maxDecimalDigits: 1)}x", + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 22), + }, + ] + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + double delay = 0; + + appear(ref delay); + + animateCountersAndScoreBars(ref delay); + + showScoreInfo(ref delay); + + updateHealthBars(ref delay); + } + + private void appear(ref double delay) + { + panelScaffold.FadeIn(100) + .ResizeTo(0) + .ResizeTo(cardSize with { Y = 30 }, 600, Easing.OutExpo) + // deliberately cutting this delay 300ms short so the vertical resize interrupts the horizontal one + .Delay(300) + .ResizeHeightTo(cardSize.Y, 800, Easing.OutExpo); + + flash.Delay(150).FadeOut(600, Easing.Out); + + using (BeginDelayedSequence(700)) + { + roundNumber.FadeIn(600); + playerScoreCounter.FadeIn(600); + opponentScoreCounter.FadeIn(600); + + Schedule(() => cornerPieceVisibility.Value = Visibility.Visible); + } + + using (BeginDelayedSequence(900)) + { + panelScaffold.BottomOrnament + .FadeIn(300) + .ResizeWidthTo(cardSize.X - 550, 600, Easing.OutExpo); + } + + delay += 1000; + } + + private void animateCountersAndScoreBars(ref double delay) + { + using (BeginDelayedSequence(delay)) + { + const double score_text_duration = 2000; + + playerScoreCounter.TransformValueTo(PlayerScore.TotalScore, score_text_duration - 500); + opponentScoreCounter.TransformValueTo(OpponentScore.TotalScore, score_text_duration - 500); + + damageCounter.TransformValueTo(losingDamageInfo.Damage, score_text_duration - 500); + + long maxAchievableScore = Math.Max( + Math.Max(PlayerScore.TotalScore, OpponentScore.TotalScore), + 1_000_000 + ); + + float playerScorePercent = (float)PlayerScore.TotalScore / maxAchievableScore; + float opponentScorePercent = (float)OpponentScore.TotalScore / maxAchievableScore; + float maxScorePercent = Math.Max(playerScorePercent, opponentScorePercent); + + playerScoreBar.FadeIn(100); + opponentScoreBar.FadeIn(100); + + this.TransformBindableTo(scoreBarProgress, maxScorePercent, score_text_duration, new CubicBezierEasingFunction(easeIn: 0.4, easeOut: 1)); + + scoreBarProgress.BindValueChanged(e => + { + playerScoreBar.Height = float.Lerp(0.05f, 1f, Math.Min(e.NewValue, playerScorePercent)); + opponentScoreBar.Height = float.Lerp(0.05f, 1f, Math.Min(e.NewValue, opponentScorePercent)); + }); + } + + delay += 2200; + } + + private void updateHealthBars(ref double delay) + { + const double text_movement_duration = 400; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + RankedPlayUserDisplay userDisplay = + PlayerScore.TotalScore > OpponentScore.TotalScore + ? opponentUserDisplay + : playerUserDisplay; + + Vector2 screenSpacePosition = userDisplay.HealthDisplay.ScreenSpaceImpactPosition; + + var position = flyingDamageText.Parent!.ToLocalSpace(screenSpacePosition) - flyingDamageText.AnchorPosition; + + damageCounter.FadeOut() + .Delay(200) + .FadeIn(300) + .ScaleTo(0.9f) + .ScaleTo(1f, 300, Easing.OutElasticHalf); + + flyingDamageText.FadeIn() + .MoveTo(position, text_movement_duration, Easing.InCubic) + .ScaleTo(0.75f, text_movement_duration, new CubicBezierEasingFunction(easeIn: 0.35, easeOut: 0.5)) + .RotateTo(12 * Math.Sign(position.X), text_movement_duration, new CubicBezierEasingFunction(easeIn: 0.35, easeOut: 0.5)) + .Then() + .FadeOut(); + + Scheduler.AddDelayed(() => + { + userDisplay.Shake(shakeDuration: 60, shakeMagnitude: 2, maximumLength: 120); + + for (int i = 0; i < 10; i++) + { + var particle = new DamageParticle + { + Size = new Vector2(RNG.NextSingle(5, 15)), + Origin = Anchor.Centre, + Position = ToLocalSpace(screenSpacePosition), + Rotation = RNG.NextSingle(0, 360), + Blending = BlendingParameters.Additive, + }; + + AddInternal(particle); + + particle.FadeOut(600) + .ScaleTo(0, 600) + .RotateTo(particle.Rotation + RNG.NextSingle(-20, 20), 600) + .FadeColour(Color4.Red, 600) + .Expire(); + } + }, text_movement_duration); + }); + } + + delay += text_movement_duration; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + playerUserDisplay.Health.Value = PlayerDamageInfo.NewLife; + opponentUserDisplay.Health.Value = OpponentDamageInfo.NewLife; + }); + } + + delay += 400; + } + + private void showScoreInfo(ref double delay) + { + using (BeginDelayedSequence(delay)) + { + playerScoreDetails.FadeIn(300); + opponentScoreDetails.FadeIn(300); + } + + delay += 800; + } + + private static int numDigits(long value) + { + if (value <= 0) + return 1; + + return (int)Math.Floor(Math.Log10(value)) + 1; + } + + private partial class DamageParticle : Triangle + { + private Vector2 velocity = new Vector2(RNG.NextSingle(-0.3f, 0.3f), RNG.NextSingle(-0.3f, 0.3f)); + + private Vector2 gravity => new Vector2(0, 0.0002f); + + protected override void Update() + { + base.Update(); + + velocity += gravity * (float)Time.Elapsed; + Position += velocity * (float)Time.Elapsed; + } + } + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs index 1bbce94f4f..e47c35c3ce 100644 --- a/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class FailRetryDisplay : CompositeDrawable + public partial class FailRetryDisplay : CompositeDrawable { private readonly GraphDrawable retriesGraph; private readonly GraphDrawable failsGraph; diff --git a/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs index 3b9d31380a..5aa8279f66 100644 --- a/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class MetadataDisplay : FillFlowContainer + public partial class MetadataDisplay : FillFlowContainer { private readonly OsuSpriteText labelText; private readonly OsuSpriteText contentText; diff --git a/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs index 3a6017f4ec..a74ab3d8b6 100644 --- a/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class RatingSpreadDisplay : CompositeDrawable + public partial class RatingSpreadDisplay : CompositeDrawable { private const float min_height = 4f; private const float max_height = 32f; diff --git a/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs index 48680b5b29..a7038ef9cb 100644 --- a/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip + public partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip { private readonly OsuSpriteText valueText; private readonly Circle backgroundBar; diff --git a/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs index 2d8d039d99..8c55e5320c 100644 --- a/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class UserRatingDisplay : CompositeDrawable + public partial class UserRatingDisplay : CompositeDrawable { private readonly OsuSpriteText negativeText; private readonly OsuSpriteText positiveText; diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index caf99a4cf6..dfb6a4fff5 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -47,6 +47,8 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.Ruleset = ruleset; BeatmapInfo.Length = 75000; + BeatmapInfo.BPM = 123; + BeatmapInfo.StarRating = 4.32; BeatmapInfo.OnlineInfo = new APIBeatmap(); BeatmapInfo.OnlineID = Interlocked.Increment(ref onlineBeatmapID); BeatmapInfo.Status = BeatmapOnlineStatus.Ranked; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 21fffba1f3..c6e39016f0 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -19,7 +19,9 @@ using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.RankedPlay; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Tests.Visual.OnlinePlay; @@ -123,6 +125,16 @@ namespace osu.Game.Tests.Visual.Multiplayer user.MatchState = new TeamVersusUserState { TeamID = bestTeam }; ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely(); break; + + case RankedPlayRoomState: + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[user.UserID] = new RankedPlayUserInfo + { + Rating = 1500, + Hand = Enumerable.Range(0, 5).Select(_ => new RankedPlayCardItem()).ToList() + }; + + ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).WaitSafely(); + break; } } @@ -428,6 +440,14 @@ namespace osu.Game.Tests.Visual.Multiplayer Action = avatarAction.Action }).ConfigureAwait(false); break; + + case RankedPlayCardHandReplayRequest cardHandState: + await ((IMultiplayerClient)this).MatchEvent(new RankedPlayCardHandReplayEvent + { + UserId = userId, + Frames = cardHandState.Frames, + }).ConfigureAwait(false); + break; } } @@ -624,6 +644,28 @@ namespace osu.Game.Tests.Visual.Multiplayer await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + break; + + case MatchType.RankedPlay: + ServerRoom.MatchState = new RankedPlayRoomState(); + + foreach (var user in ServerRoom.Users) + { + ((RankedPlayRoomState)ServerRoom.MatchState).Users[user.UserID] = new RankedPlayUserInfo + { + Rating = 1500, + Hand = Enumerable.Range(0, 5).Select(_ => new RankedPlayCardItem()).ToList() + }; + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + + foreach (var user in ServerRoom.Users) + { + user.MatchState = null; + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); + } + break; } } @@ -782,6 +824,33 @@ namespace osu.Game.Tests.Visual.Multiplayer await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); } + public override Task DiscardCards(RankedPlayCardItem[] cards) + => DiscardCards(_ => cards); + + public Task DiscardCards(Func> selector) + => DiscardUserCards(api.LocalUser.Value.OnlineID, selector); + + public async Task DiscardUserCards(int userId, Func> selector) + { + RankedPlayUserInfo info = ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId]; + RankedPlayCardItem[] cards = selector(info.Hand.ToArray()).ToArray(); + + await RankedPlayRemoveUserCards(userId, _ => cards).ConfigureAwait(false); + await RankedPlayAddUserCards(userId, Enumerable.Range(0, cards.Length).Select(_ => new RankedPlayCardItem()).ToArray()).ConfigureAwait(false); + } + + public override Task PlayCard(RankedPlayCardItem card) + => PlayCard(_ => card); + + public Task PlayCard(Func selector) + => PlayUserCard(api.LocalUser.Value.OnlineID, selector); + + public async Task PlayUserCard(int userId, Func selector) + { + RankedPlayCardItem card = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + await ((IRankedPlayClient)this).RankedPlayCardPlayed(clone(card)).ConfigureAwait(false); + } + public override Task GetMatchmakingPoolsOfType(MatchmakingPoolType type) { return Task.FromResult( @@ -865,6 +934,93 @@ namespace osu.Game.Tests.Visual.Multiplayer }).ConfigureAwait(false); } + /// + /// Adds a card to the local user's hand. + /// + public Task RankedPlayAddCards(RankedPlayCardItem[] cards) + => RankedPlayAddUserCards(api.LocalUser.Value.OnlineID, cards); + + /// + /// Adds a card to the given user's hand. + /// + public async Task RankedPlayAddUserCards(int userId, RankedPlayCardItem[] cards) + { + foreach (var card in cards) + { + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.Add(card); + await ((IRankedPlayClient)this).RankedPlayCardAdded(userId, clone(card)).ConfigureAwait(false); + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + + /// + /// Removes a card from the local user's hand. + /// + public Task RankedPlayRemoveCards(Func selector) + => RankedPlayRemoveUserCards(api.LocalUser.Value.OnlineID, selector); + + /// + /// Removes a card from the given user's hand. + /// + public async Task RankedPlayRemoveUserCards(int userId, Func selector) + { + RankedPlayCardItem[] cards = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + + foreach (var card in cards) + { + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.Remove(card); + await ((IRankedPlayClient)this).RankedPlayCardRemoved(userId, clone(card)).ConfigureAwait(false); + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + + /// + /// Reveals a card in the local user's hand. + /// + public Task RankedPlayRevealCard(Func selector, MultiplayerPlaylistItem item) + => RankedPlayRevealUserCard(api.LocalUser.Value.OnlineID, selector, item); + + /// + /// Reveals a card in the given user's hand. + /// + public async Task RankedPlayRevealUserCard(int userId, Func selector, MultiplayerPlaylistItem item) + { + RankedPlayCardItem card = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + await ((IRankedPlayClient)this).RankedPlayCardRevealed(clone(card), clone(item)).ConfigureAwait(false); + } + + public async Task RankedPlayChangeStage(RankedPlayStage stage, Action? prepare = null) + { + RankedPlayRoomState state = clone((RankedPlayRoomState)ServerRoom!.MatchState!); + + state.Stage = stage; + + if (stage == RankedPlayStage.RoundWarmup) + state.CurrentRound++; + + prepare?.Invoke(state); + + await ChangeMatchRoomState(state).ConfigureAwait(false); + await StartCountdown(new RankedPlayStageCountdown + { + Stage = stage, + TimeRemaining = TimeSpan.FromSeconds(stage == RankedPlayStage.CardPlay ? 30 : 10) + }).ConfigureAwait(false); + } + + public async Task RankedPlayChangeUserState(int userId, Action prepare) + { + Debug.Assert(ServerRoom != null); + + var userInfo = clone(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId]); + prepare(userInfo); + + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId] = userInfo; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + #region API Room Handling public IReadOnlyList ServerSideRooms diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 9b0b66a18c..8c2ba93883 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -274,6 +274,8 @@ namespace osu.Game.Tests.Visual var result = new APIBeatmapSet { + Genre = new BeatmapSetOnlineGenre { Id = 15, Name = "Future genre" }, + Language = new BeatmapSetOnlineLanguage { Id = 15, Name = "Future language" }, OnlineID = original.BeatmapSet.OnlineID, Status = BeatmapOnlineStatus.Ranked, Covers = new BeatmapSetOnlineCovers @@ -293,6 +295,34 @@ namespace osu.Game.Tests.Visual }, Source = original.Metadata.Source, Tags = original.Metadata.Tags, + BPM = original.BPM, + HasFavourited = false, + PlayCount = 123, + FavouriteCount = 456, + Submitted = DateTime.Now, + Ranked = DateTime.Now, + Ratings = Enumerable.Range(0, 11).ToArray(), + RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ], Beatmaps = new[] { new APIBeatmap @@ -305,10 +335,30 @@ namespace osu.Game.Tests.Visual RulesetID = original.Ruleset.OnlineID, StarRating = original.StarRating, DifficultyName = original.DifficultyName, + CircleSize = original.Difficulty.CircleSize, + DrainRate = original.Difficulty.DrainRate, + OverallDifficulty = original.Difficulty.OverallDifficulty, + ApproachRate = original.Difficulty.ApproachRate, + Length = original.Length, + HitLength = original.Length, + CircleCount = 111, + SliderCount = 12, + PlayCount = 222, + BPM = original.BPM, + PassCount = 21, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ], } - }, - HasFavourited = false, - FavouriteCount = 0, + } }; foreach (var beatmap in result.Beatmaps) diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 86c84c0bb2..d9aa772f66 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -274,15 +274,22 @@ namespace osu.Game.Users public InLobby(MultiplayerRoom room) { - if (room.Settings.MatchType == MatchType.Matchmaking) + switch (room.Settings.MatchType) { - RoomID = -1; - RoomName = "Quick Play"; - } - else - { - RoomID = room.RoomID; - RoomName = room.Settings.Name; + case MatchType.Matchmaking: + RoomID = -1; + RoomName = "Quick Play"; + break; + + case MatchType.RankedPlay: + RoomID = -1; + RoomName = "Ranked Play"; + break; + + default: + RoomID = room.RoomID; + RoomName = room.Settings.Name; + break; } }