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; } }