Merge branch 'master' into fix-results-f-rank

This commit is contained in:
Salman Ahmed 2024-02-01 15:29:07 +03:00
commit 35c3f5e250
156 changed files with 2271 additions and 760 deletions

View File

@ -35,7 +35,7 @@
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl> <RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<PackageReleaseNotes>Automated release.</PackageReleaseNotes> <PackageReleaseNotes>Automated release.</PackageReleaseNotes>
<Company>ppy Pty Ltd</Company> <Company>ppy Pty Ltd</Company>
<Copyright>Copyright (c) 2022 ppy Pty Ltd</Copyright> <Copyright>Copyright (c) 2024 ppy Pty Ltd</Copyright>
<PackageTags>osu game</PackageTags> <PackageTags>osu game</PackageTags>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -1,4 +1,4 @@
Copyright (c) 2022 ppy Pty Ltd <contact@ppy.sh>. Copyright (c) 2024 ppy Pty Ltd <contact@ppy.sh>.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -22,7 +22,7 @@ A few resources are available as starting points to getting involved and underst
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). - You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
- Track our current efforts [towards full "ranked play" support](https://github.com/orgs/ppy/projects/13?query=is%3Aopen+sort%3Aupdated-desc). - Track our current efforts [towards improving the game](https://github.com/orgs/ppy/projects/7/views/6).
## Running osu! ## Running osu!

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<PackageType>Template</PackageType> <PackageType>Template</PackageType>
<PackageId>ppy.osu.Game.Templates</PackageId> <PackageId>ppy.osu.Game.Templates</PackageId>
@ -8,7 +8,7 @@
<PackageProjectUrl>https://github.com/ppy/osu/blob/master/Templates</PackageProjectUrl> <PackageProjectUrl>https://github.com/ppy/osu/blob/master/Templates</PackageProjectUrl>
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl> <RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<PackageReleaseNotes>Automated release.</PackageReleaseNotes> <PackageReleaseNotes>Automated release.</PackageReleaseNotes>
<copyright>Copyright (c) 2022 ppy Pty Ltd</copyright> <copyright>Copyright (c) 2024 ppy Pty Ltd</copyright>
<Description>Templates to use when creating a ruleset for consumption in osu!.</Description> <Description>Templates to use when creating a ruleset for consumption in osu!.</Description>
<PackageTags>dotnet-new;templates;osu</PackageTags> <PackageTags>dotnet-new;templates;osu</PackageTags>
<TargetFramework>netstandard2.1</TargetFramework> <TargetFramework>netstandard2.1</TargetFramework>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 321 KiB

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.114.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2024.131.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@drawable/lazer" /> <application android:allowBackup="true"
android:supportsRtl="true"
android:label="osu!"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher" />
<!-- for editor usage --> <!-- for editor usage -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

View File

@ -0,0 +1,618 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.67"
android:scaleY="0.67"
android:translateX="17.82"
android:translateY="17.82">
<group>
<clip-path
android:pathData="M0,0h108v108h-108z"/>
<path
android:pathData="M109.48,-1.48H-1.48V109.48H109.48V-1.48Z"
android:fillColor="#404041"/>
<group>
<clip-path
android:pathData="M0,-0.31H108V107.69H0V-0.31Z"/>
<group>
<clip-path
android:pathData="M215.01,-78.1H-78.39V215.3H215.01V-78.1Z"/>
<group>
<clip-path
android:pathData="M149.56,96.97H96.68V149.85H149.56V96.97Z"/>
<group>
<clip-path
android:pathData="M149.56,96.97H96.68V149.85H149.56V96.97Z"/>
<path
android:pathData="M100.57,98.05C100.34,98.05 100.15,98.24 100.15,98.47V102.61C100.15,102.84 100.34,103.03 100.57,103.03C100.79,103.03 100.98,102.84 100.98,102.61V98.47C100.98,98.24 100.79,98.05 100.57,98.05Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M101.86,99.22C101.63,99.22 101.44,99.41 101.44,99.63V101.45C101.44,101.67 101.63,101.86 101.86,101.86C102.08,101.86 102.27,101.67 102.27,101.45V99.63C102.27,99.41 102.08,99.22 101.86,99.22Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M104.44,100.54C104.44,101.06 104.33,101.57 104.13,102.05C103.94,102.51 103.65,102.92 103.3,103.28C102.95,103.63 102.53,103.91 102.07,104.11C101.59,104.31 101.09,104.41 100.57,104.41C100.04,104.41 99.54,104.31 99.06,104.11C98.6,103.91 98.19,103.63 97.83,103.28C97.48,102.92 97.2,102.51 97,102.05C96.8,101.57 96.7,101.06 96.7,100.54C96.7,100.02 96.8,99.51 97,99.04C97.2,98.58 97.48,98.16 97.83,97.81C98.19,97.45 98.6,97.18 99.06,96.98C99.54,96.78 100.04,96.67 100.57,96.67C101.09,96.67 101.59,96.77 102.07,96.98C102.53,97.17 102.95,97.45 103.3,97.81C103.65,98.16 103.93,98.58 104.13,99.04C104.33,99.51 104.44,100.02 104.44,100.54ZM103.78,100.54C103.78,98.77 102.34,97.33 100.57,97.33C98.8,97.33 97.36,98.77 97.36,100.54C97.36,102.31 98.8,103.75 100.57,103.75C102.34,103.75 103.78,102.31 103.78,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M99.28,101.86C99.51,101.86 99.69,101.67 99.69,101.45V99.64C99.69,99.41 99.51,99.23 99.28,99.23C99.05,99.23 98.87,99.41 98.87,99.64V101.45C98.87,101.68 99.05,101.86 99.28,101.86Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M149.56,44.09H96.68V96.97H149.56V44.09Z"/>
<group>
<clip-path
android:pathData="M149.56,44.09H96.68V96.97H149.56V44.09Z"/>
<path
android:pathData="M104.44,100.54C104.44,101.06 104.33,101.57 104.13,102.05C103.94,102.51 103.65,102.92 103.3,103.28C102.95,103.63 102.53,103.91 102.07,104.11C101.59,104.31 101.09,104.41 100.57,104.41C100.04,104.41 99.54,104.31 99.06,104.11C98.6,103.91 98.19,103.63 97.83,103.28C97.48,102.92 97.2,102.51 97,102.05C96.8,101.57 96.7,101.06 96.7,100.54C96.7,100.02 96.8,99.51 97,99.04C97.2,98.58 97.48,98.16 97.83,97.81C98.19,97.45 98.6,97.18 99.06,96.98C99.54,96.78 100.04,96.67 100.57,96.67C101.09,96.67 101.59,96.77 102.07,96.98C102.53,97.17 102.95,97.45 103.3,97.81C103.65,98.16 103.93,98.58 104.13,99.04C104.33,99.51 104.44,100.02 104.44,100.54ZM103.78,100.54C103.78,98.77 102.34,97.33 100.57,97.33C98.8,97.33 97.36,98.77 97.36,100.54C97.36,102.31 98.8,103.75 100.57,103.75C102.34,103.75 103.78,102.31 103.78,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M100.57,70.97C100.34,70.97 100.15,71.16 100.15,71.38V75.53C100.15,75.76 100.34,75.94 100.57,75.94C100.79,75.94 100.98,75.76 100.98,75.53V71.38C100.98,71.15 100.79,70.97 100.57,70.97Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M101.86,72.14C101.63,72.14 101.44,72.33 101.44,72.55V74.36C101.44,74.59 101.63,74.77 101.86,74.77C102.08,74.77 102.27,74.59 102.27,74.36V72.55C102.27,72.32 102.08,72.14 101.86,72.14Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M99.28,74.78C99.51,74.78 99.69,74.59 99.69,74.37V72.55C99.69,72.33 99.51,72.14 99.28,72.14C99.05,72.14 98.87,72.33 98.87,72.55V74.37C98.87,74.59 99.05,74.78 99.28,74.78Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M100.57,45.18C100.34,45.18 100.15,45.37 100.15,45.59V49.74C100.15,49.97 100.34,50.15 100.57,50.15C100.79,50.15 100.98,49.97 100.98,49.74V45.59C100.98,45.36 100.79,45.18 100.57,45.18Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M101.86,46.35C101.63,46.35 101.44,46.53 101.44,46.76V48.57C101.44,48.8 101.63,48.98 101.86,48.98C102.08,48.98 102.27,48.79 102.27,48.57V46.76C102.27,46.53 102.08,46.35 101.86,46.35Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M104.44,47.67C104.44,48.19 104.33,48.69 104.13,49.17C103.94,49.63 103.65,50.04 103.3,50.4C102.95,50.75 102.53,51.03 102.07,51.23C101.59,51.43 101.09,51.53 100.57,51.53C100.04,51.53 99.54,51.43 99.06,51.23C98.6,51.03 98.19,50.75 97.83,50.4C97.48,50.04 97.2,49.63 97,49.17C96.8,48.69 96.7,48.19 96.7,47.67C96.7,47.14 96.8,46.64 97,46.16C97.2,45.7 97.48,45.29 97.83,44.93C98.19,44.58 98.6,44.3 99.06,44.1C99.54,43.9 100.04,43.8 100.57,43.8C101.09,43.8 101.59,43.9 102.07,44.1C102.53,44.3 102.95,44.58 103.3,44.93C103.65,45.29 103.93,45.7 104.13,46.16C104.33,46.64 104.44,47.14 104.44,47.67ZM103.78,47.67C103.78,45.89 102.34,44.46 100.57,44.46C98.8,44.46 97.36,45.89 97.36,47.67C97.36,49.44 98.8,50.87 100.57,50.87C102.34,50.87 103.78,49.44 103.78,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M104.44,73.46C104.44,73.98 104.33,74.49 104.13,74.96C103.94,75.42 103.65,75.84 103.3,76.19C102.95,76.55 102.53,76.83 102.07,77.02C101.59,77.22 101.09,77.33 100.57,77.33C100.04,77.33 99.54,77.23 99.06,77.02C98.6,76.83 98.19,76.55 97.83,76.19C97.48,75.84 97.2,75.42 97,74.96C96.8,74.49 96.7,73.98 96.7,73.46C96.7,72.94 96.8,72.43 97,71.95C97.2,71.49 97.48,71.08 97.83,70.72C98.19,70.37 98.6,70.09 99.06,69.89C99.54,69.69 100.04,69.59 100.57,69.59C101.09,69.59 101.59,69.69 102.07,69.89C102.53,70.09 102.95,70.37 103.3,70.72C103.65,71.08 103.93,71.49 104.13,71.95C104.33,72.43 104.44,72.94 104.44,73.46ZM103.78,73.46C103.78,71.68 102.34,70.25 100.57,70.25C98.8,70.25 97.36,71.69 97.36,73.46C97.36,75.23 98.8,76.67 100.57,76.67C102.34,76.67 103.78,75.23 103.78,73.46Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M99.28,48.98C99.51,48.98 99.69,48.8 99.69,48.57V46.76C99.69,46.53 99.51,46.35 99.28,46.35C99.05,46.35 98.87,46.54 98.87,46.76V48.57C98.87,48.8 99.05,48.98 99.28,48.98Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M149.56,-8.78H96.68V44.09H149.56V-8.78Z"/>
<group>
<clip-path
android:pathData="M149.56,-8.78H96.68V44.09H149.56V-8.78Z"/>
<path
android:pathData="M104.44,47.67C104.44,48.19 104.33,48.69 104.13,49.17C103.94,49.63 103.65,50.04 103.3,50.4C102.95,50.75 102.53,51.03 102.07,51.23C101.59,51.43 101.09,51.53 100.57,51.53C100.04,51.53 99.54,51.43 99.06,51.23C98.6,51.03 98.19,50.75 97.83,50.4C97.48,50.04 97.2,49.63 97,49.17C96.8,48.69 96.7,48.19 96.7,47.67C96.7,47.14 96.8,46.64 97,46.16C97.2,45.7 97.48,45.29 97.83,44.93C98.19,44.58 98.6,44.3 99.06,44.1C99.54,43.9 100.04,43.8 100.57,43.8C101.09,43.8 101.59,43.9 102.07,44.1C102.53,44.3 102.95,44.58 103.3,44.93C103.65,45.29 103.93,45.7 104.13,46.16C104.33,46.64 104.44,47.14 104.44,47.67ZM103.78,47.67C103.78,45.89 102.34,44.46 100.57,44.46C98.8,44.46 97.36,45.89 97.36,47.67C97.36,49.44 98.8,50.87 100.57,50.87C102.34,50.87 103.78,49.44 103.78,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M100.57,18.1C100.34,18.1 100.15,18.28 100.15,18.51V22.66C100.15,22.89 100.34,23.07 100.57,23.07C100.79,23.07 100.98,22.88 100.98,22.66V18.51C100.98,18.28 100.79,18.1 100.57,18.1Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M101.86,19.27C101.63,19.27 101.44,19.45 101.44,19.68V21.49C101.44,21.72 101.63,21.9 101.86,21.9C102.08,21.9 102.27,21.71 102.27,21.49V19.68C102.27,19.45 102.08,19.27 101.86,19.27Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M99.28,21.9C99.51,21.9 99.69,21.71 99.69,21.49V19.68C99.69,19.45 99.51,19.27 99.28,19.27C99.05,19.27 98.87,19.45 98.87,19.68V21.49C98.87,21.72 99.05,21.9 99.28,21.9Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M104.44,20.58C104.44,21.1 104.33,21.61 104.13,22.09C103.94,22.55 103.65,22.96 103.3,23.32C102.95,23.67 102.53,23.95 102.07,24.15C101.59,24.35 101.09,24.45 100.57,24.45C100.04,24.45 99.54,24.35 99.06,24.15C98.6,23.95 98.19,23.67 97.83,23.32C97.48,22.96 97.2,22.55 97,22.09C96.8,21.61 96.7,21.1 96.7,20.58C96.7,20.06 96.8,19.55 97,19.08C97.2,18.62 97.48,18.2 97.83,17.85C98.19,17.49 98.6,17.22 99.06,17.02C99.54,16.82 100.04,16.71 100.57,16.71C101.09,16.71 101.59,16.81 102.07,17.02C102.53,17.21 102.95,17.49 103.3,17.85C103.65,18.2 103.93,18.62 104.13,19.08C104.33,19.55 104.44,20.06 104.44,20.58ZM103.78,20.58C103.78,18.81 102.34,17.37 100.57,17.37C98.8,17.37 97.36,18.81 97.36,20.58C97.36,22.35 98.8,23.79 100.57,23.79C102.34,23.79 103.78,22.35 103.78,20.58Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M96.68,96.97H43.8V149.85H96.68V96.97Z"/>
<group>
<clip-path
android:pathData="M96.68,96.97H43.8V149.85H96.68V96.97Z"/>
<path
android:pathData="M73.51,98.07C72.15,98.07 71.04,99.18 71.04,100.54C71.04,101.91 72.15,103.01 73.51,103.01C74.87,103.01 75.98,101.91 75.98,100.54C75.98,99.18 74.87,98.07 73.51,98.07Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M77.38,100.54C77.38,101.06 77.28,101.57 77.07,102.05C76.88,102.51 76.6,102.92 76.24,103.28C75.89,103.63 75.48,103.91 75.02,104.11C74.54,104.31 74.03,104.41 73.51,104.41C72.99,104.41 72.48,104.31 72.01,104.11C71.55,103.91 71.13,103.63 70.78,103.28C70.42,102.92 70.14,102.51 69.95,102.05C69.75,101.57 69.64,101.06 69.64,100.54C69.64,100.02 69.74,99.51 69.95,99.04C70.14,98.58 70.42,98.16 70.78,97.81C71.13,97.45 71.55,97.18 72.01,96.98C72.48,96.78 72.99,96.67 73.51,96.67C74.03,96.67 74.54,96.77 75.02,96.98C75.48,97.17 75.89,97.45 76.24,97.81C76.6,98.16 76.88,98.58 77.07,99.04C77.28,99.51 77.38,100.02 77.38,100.54ZM76.72,100.54C76.72,98.77 75.28,97.33 73.51,97.33C71.74,97.33 70.3,98.77 70.3,100.54C70.3,102.31 71.74,103.75 73.51,103.75C75.28,103.75 76.72,102.31 76.72,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M47.69,98.05C47.46,98.05 47.28,98.24 47.28,98.47V102.61C47.28,102.84 47.46,103.03 47.69,103.03C47.92,103.03 48.11,102.84 48.11,102.61V98.47C48.11,98.24 47.92,98.05 47.69,98.05Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M48.98,99.22C48.75,99.22 48.57,99.41 48.57,99.63V101.45C48.57,101.67 48.75,101.86 48.98,101.86C49.21,101.86 49.4,101.67 49.4,101.45V99.63C49.4,99.41 49.21,99.22 48.98,99.22Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,100.54C51.56,101.06 51.46,101.57 51.26,102.05C51.06,102.51 50.78,102.92 50.43,103.28C50.07,103.63 49.66,103.91 49.2,104.11C48.72,104.31 48.21,104.41 47.69,104.41C47.17,104.41 46.67,104.31 46.19,104.11C45.73,103.91 45.31,103.63 44.96,103.28C44.6,102.92 44.33,102.51 44.13,102.05C43.93,101.57 43.82,101.06 43.82,100.54C43.82,100.02 43.93,99.51 44.13,99.04C44.32,98.58 44.6,98.16 44.96,97.81C45.31,97.45 45.73,97.18 46.19,96.98C46.67,96.78 47.17,96.67 47.69,96.67C48.21,96.67 48.72,96.77 49.2,96.98C49.66,97.17 50.07,97.45 50.43,97.81C50.78,98.16 51.06,98.58 51.26,99.04C51.46,99.51 51.56,100.02 51.56,100.54ZM50.9,100.54C50.9,98.77 49.46,97.33 47.69,97.33C45.92,97.33 44.48,98.77 44.48,100.54C44.48,102.31 45.92,103.75 47.69,103.75C49.46,103.75 50.9,102.31 50.9,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M46.4,101.86C46.63,101.86 46.82,101.67 46.82,101.45V99.64C46.82,99.41 46.63,99.23 46.4,99.23C46.17,99.23 45.99,99.41 45.99,99.64V101.45C45.99,101.68 46.17,101.86 46.4,101.86Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M96.68,44.09H43.8V96.97H96.68V44.09Z"/>
<group>
<clip-path
android:pathData="M96.68,44.09H43.8V96.97H96.68V44.09Z"/>
<path
android:pathData="M77.38,100.54C77.38,101.06 77.28,101.57 77.07,102.05C76.88,102.51 76.6,102.92 76.24,103.28C75.89,103.63 75.48,103.91 75.02,104.11C74.54,104.31 74.03,104.41 73.51,104.41C72.99,104.41 72.48,104.31 72.01,104.11C71.55,103.91 71.13,103.63 70.78,103.28C70.42,102.92 70.14,102.51 69.95,102.05C69.75,101.57 69.64,101.06 69.64,100.54C69.64,100.02 69.74,99.51 69.95,99.04C70.14,98.58 70.42,98.16 70.78,97.81C71.13,97.45 71.55,97.18 72.01,96.98C72.48,96.78 72.99,96.67 73.51,96.67C74.03,96.67 74.54,96.77 75.02,96.98C75.48,97.17 75.89,97.45 76.24,97.81C76.6,98.16 76.88,98.58 77.07,99.04C77.28,99.51 77.38,100.02 77.38,100.54ZM76.72,100.54C76.72,98.77 75.28,97.33 73.51,97.33C71.74,97.33 70.3,98.77 70.3,100.54C70.3,102.31 71.74,103.75 73.51,103.75C75.28,103.75 76.72,102.31 76.72,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,100.54C51.56,101.06 51.46,101.57 51.26,102.05C51.06,102.51 50.78,102.92 50.43,103.28C50.07,103.63 49.66,103.91 49.2,104.11C48.72,104.31 48.21,104.41 47.69,104.41C47.17,104.41 46.67,104.31 46.19,104.11C45.73,103.91 45.31,103.63 44.96,103.28C44.6,102.92 44.33,102.51 44.13,102.05C43.93,101.57 43.82,101.06 43.82,100.54C43.82,100.02 43.93,99.51 44.13,99.04C44.32,98.58 44.6,98.16 44.96,97.81C45.31,97.45 45.73,97.18 46.19,96.98C46.67,96.78 47.17,96.67 47.69,96.67C48.21,96.67 48.72,96.77 49.2,96.98C49.66,97.17 50.07,97.45 50.43,97.81C50.78,98.16 51.06,98.58 51.26,99.04C51.46,99.51 51.56,100.02 51.56,100.54ZM50.9,100.54C50.9,98.77 49.46,97.33 47.69,97.33C45.92,97.33 44.48,98.77 44.48,100.54C44.48,102.31 45.92,103.75 47.69,103.75C49.46,103.75 50.9,102.31 50.9,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M47.69,70.97C47.46,70.97 47.28,71.16 47.28,71.38V75.53C47.28,75.76 47.46,75.94 47.69,75.94C47.92,75.94 48.11,75.76 48.11,75.53V71.38C48.11,71.15 47.92,70.97 47.69,70.97Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M48.98,72.14C48.75,72.14 48.57,72.33 48.57,72.55V74.36C48.57,74.59 48.75,74.77 48.98,74.77C49.21,74.77 49.4,74.59 49.4,74.36V72.55C49.4,72.32 49.21,72.14 48.98,72.14Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M46.4,74.78C46.63,74.78 46.82,74.59 46.82,74.37V72.55C46.82,72.33 46.63,72.14 46.4,72.14C46.17,72.14 45.99,72.33 45.99,72.55V74.37C45.99,74.59 46.17,74.78 46.4,74.78Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M73.51,45.2C72.15,45.2 71.04,46.3 71.04,47.67C71.04,49.03 72.15,50.13 73.51,50.13C74.87,50.13 75.98,49.03 75.98,47.67C75.98,46.3 74.87,45.2 73.51,45.2Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M77.38,47.67C77.38,48.19 77.28,48.69 77.07,49.17C76.88,49.63 76.6,50.04 76.24,50.4C75.89,50.75 75.48,51.03 75.02,51.23C74.54,51.43 74.03,51.53 73.51,51.53C72.99,51.53 72.48,51.43 72.01,51.23C71.55,51.03 71.13,50.75 70.78,50.4C70.42,50.04 70.14,49.63 69.95,49.17C69.75,48.69 69.64,48.19 69.64,47.67C69.64,47.14 69.74,46.64 69.95,46.16C70.14,45.7 70.42,45.29 70.78,44.93C71.13,44.58 71.55,44.3 72.01,44.1C72.48,43.9 72.99,43.8 73.51,43.8C74.03,43.8 74.54,43.9 75.02,44.1C75.48,44.3 75.89,44.58 76.24,44.93C76.6,45.29 76.88,45.7 77.07,46.16C77.28,46.64 77.38,47.14 77.38,47.67ZM76.72,47.67C76.72,45.89 75.28,44.46 73.51,44.46C71.74,44.46 70.3,45.89 70.3,47.67C70.3,49.44 71.74,50.87 73.51,50.87C75.28,50.87 76.72,49.44 76.72,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M90.28,60.56C90.28,61.08 90.17,61.59 89.97,62.07C89.78,62.53 89.5,62.94 89.14,63.3C88.79,63.65 88.37,63.93 87.91,64.13C87.43,64.33 86.93,64.43 86.41,64.43C85.88,64.43 85.38,64.33 84.9,64.13C84.44,63.93 84.03,63.65 83.67,63.3C83.32,62.94 83.04,62.53 82.84,62.07C82.64,61.59 82.54,61.08 82.54,60.56C82.54,60.04 82.64,59.54 82.84,59.06C83.04,58.6 83.32,58.18 83.67,57.83C84.03,57.47 84.44,57.2 84.9,57C85.38,56.8 85.88,56.69 86.41,56.69C86.93,56.69 87.43,56.8 87.91,57C88.37,57.19 88.79,57.47 89.14,57.83C89.5,58.18 89.77,58.6 89.97,59.06C90.17,59.54 90.28,60.04 90.28,60.56ZM89.62,60.56C89.62,58.79 88.18,57.35 86.41,57.35C84.64,57.35 83.2,58.79 83.2,60.56C83.2,62.33 84.64,63.77 86.41,63.77C88.18,63.77 89.62,62.33 89.62,60.56Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M87.38,59.95C87.03,59.95 86.76,60.22 86.76,60.56C86.76,60.9 87.04,61.18 87.38,61.18C87.71,61.18 87.99,60.9 87.99,60.56C87.99,60.22 87.71,59.95 87.38,59.95Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,58.69C85.59,58.69 85.32,58.97 85.32,59.31C85.32,59.65 85.59,59.92 85.93,59.92C86.27,59.92 86.55,59.65 86.55,59.31C86.55,58.97 86.27,58.69 85.93,58.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,61.2C85.59,61.2 85.32,61.48 85.32,61.82C85.32,62.16 85.59,62.43 85.93,62.43C86.27,62.43 86.55,62.16 86.55,61.82C86.55,61.48 86.27,61.2 85.93,61.2Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M73.51,70.99C72.15,70.99 71.04,72.09 71.04,73.46C71.04,74.82 72.15,75.93 73.51,75.93C74.87,75.93 75.98,74.82 75.98,73.46C75.98,72.09 74.87,70.99 73.51,70.99Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M77.38,73.46C77.38,73.98 77.28,74.49 77.07,74.96C76.88,75.42 76.6,75.84 76.24,76.19C75.89,76.55 75.48,76.83 75.02,77.02C74.54,77.22 74.03,77.33 73.51,77.33C72.99,77.33 72.48,77.23 72.01,77.02C71.55,76.83 71.13,76.55 70.78,76.19C70.42,75.84 70.14,75.42 69.95,74.96C69.75,74.49 69.64,73.98 69.64,73.46C69.64,72.94 69.74,72.43 69.95,71.95C70.14,71.49 70.42,71.08 70.78,70.72C71.13,70.37 71.55,70.09 72.01,69.89C72.48,69.69 72.99,69.59 73.51,69.59C74.03,69.59 74.54,69.69 75.02,69.89C75.48,70.09 75.89,70.37 76.24,70.72C76.6,71.08 76.88,71.49 77.07,71.95C77.28,72.43 77.38,72.94 77.38,73.46ZM76.72,73.46C76.72,71.68 75.28,70.25 73.51,70.25C71.74,70.25 70.3,71.69 70.3,73.46C70.3,75.23 71.74,76.67 73.51,76.67C75.28,76.67 76.72,75.23 76.72,73.46Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M90.28,86.36C90.28,86.88 90.17,87.38 89.97,87.86C89.78,88.32 89.5,88.74 89.14,89.09C88.79,89.45 88.37,89.72 87.91,89.92C87.43,90.12 86.93,90.23 86.41,90.23C85.88,90.23 85.38,90.12 84.9,89.92C84.44,89.73 84.03,89.45 83.67,89.09C83.32,88.74 83.04,88.32 82.84,87.86C82.64,87.38 82.54,86.88 82.54,86.36C82.54,85.84 82.64,85.33 82.84,84.85C83.04,84.39 83.32,83.98 83.67,83.62C84.03,83.27 84.44,82.99 84.9,82.79C85.38,82.59 85.88,82.49 86.41,82.49C86.93,82.49 87.43,82.59 87.91,82.79C88.37,82.99 88.79,83.27 89.14,83.62C89.5,83.98 89.77,84.39 89.97,84.85C90.17,85.33 90.28,85.84 90.28,86.36ZM89.62,86.36C89.62,84.58 88.18,83.15 86.41,83.15C84.64,83.15 83.2,84.59 83.2,86.36C83.2,88.13 84.64,89.57 86.41,89.57C88.18,89.57 89.62,88.13 89.62,86.36Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M87.38,85.74C87.03,85.74 86.76,86.01 86.76,86.35C86.76,86.69 87.04,86.97 87.38,86.97C87.71,86.97 87.99,86.69 87.99,86.35C87.99,86.01 87.71,85.74 87.38,85.74Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,84.48C85.59,84.48 85.32,84.76 85.32,85.1C85.32,85.44 85.59,85.72 85.93,85.72C86.27,85.72 86.55,85.44 86.55,85.1C86.55,84.76 86.27,84.48 85.93,84.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,86.99C85.59,86.99 85.32,87.27 85.32,87.61C85.32,87.95 85.59,88.23 85.93,88.23C86.27,88.23 86.55,87.95 86.55,87.61C86.55,87.27 86.27,86.99 85.93,86.99Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M47.69,45.18C47.46,45.18 47.28,45.37 47.28,45.59V49.74C47.28,49.97 47.46,50.15 47.69,50.15C47.92,50.15 48.11,49.97 48.11,49.74V45.59C48.11,45.36 47.92,45.18 47.69,45.18Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M48.98,46.35C48.75,46.35 48.57,46.53 48.57,46.76V48.57C48.57,48.8 48.75,48.98 48.98,48.98C49.21,48.98 49.4,48.79 49.4,48.57V46.76C49.4,46.53 49.21,46.35 48.98,46.35Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,47.67C51.56,48.19 51.46,48.69 51.26,49.17C51.06,49.63 50.78,50.04 50.43,50.4C50.07,50.75 49.66,51.03 49.2,51.23C48.72,51.43 48.21,51.53 47.69,51.53C47.17,51.53 46.67,51.43 46.19,51.23C45.73,51.03 45.31,50.75 44.96,50.4C44.6,50.04 44.33,49.63 44.13,49.17C43.93,48.69 43.82,48.19 43.82,47.67C43.82,47.14 43.93,46.64 44.13,46.16C44.32,45.7 44.6,45.29 44.96,44.93C45.31,44.58 45.73,44.3 46.19,44.1C46.67,43.9 47.17,43.8 47.69,43.8C48.21,43.8 48.72,43.9 49.2,44.1C49.66,44.3 50.07,44.58 50.43,44.93C50.78,45.29 51.06,45.7 51.26,46.16C51.46,46.64 51.56,47.14 51.56,47.67ZM50.9,47.67C50.9,45.89 49.46,44.46 47.69,44.46C45.92,44.46 44.48,45.89 44.48,47.67C44.48,49.44 45.92,50.87 47.69,50.87C49.46,50.87 50.9,49.44 50.9,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M63.06,60.56C63.06,61.93 61.96,63.03 60.59,63.03C59.23,63.03 58.12,61.93 58.12,60.56C58.12,59.2 59.23,58.09 60.59,58.09C61.96,58.09 63.06,59.2 63.06,60.56ZM60.18,62.16V58.97C59.48,59.15 58.94,59.8 58.94,60.56C58.94,61.33 59.48,61.97 60.18,62.16M62.24,60.56C62.24,59.8 61.7,59.15 61,58.97V62.16C61.7,61.97 62.24,61.33 62.24,60.56"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M64.46,60.56C64.46,61.08 64.36,61.59 64.15,62.07C63.96,62.53 63.68,62.94 63.32,63.3C62.97,63.65 62.55,63.93 62.09,64.13C61.62,64.33 61.11,64.43 60.59,64.43C60.07,64.43 59.56,64.33 59.08,64.13C58.62,63.93 58.21,63.65 57.85,63.3C57.5,62.94 57.22,62.53 57.02,62.07C56.82,61.59 56.72,61.08 56.72,60.56C56.72,60.04 56.82,59.54 57.02,59.06C57.22,58.6 57.5,58.18 57.85,57.83C58.21,57.47 58.62,57.2 59.08,57C59.56,56.8 60.07,56.69 60.59,56.69C61.11,56.69 61.62,56.8 62.09,57C62.55,57.19 62.97,57.47 63.32,57.83C63.68,58.18 63.95,58.6 64.15,59.06C64.35,59.54 64.46,60.04 64.46,60.56ZM63.8,60.56C63.8,58.79 62.36,57.35 60.59,57.35C58.82,57.35 57.38,58.79 57.38,60.56C57.38,62.33 58.82,63.77 60.59,63.77C62.36,63.77 63.8,62.33 63.8,60.56Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,73.46C51.56,73.98 51.46,74.49 51.26,74.96C51.06,75.42 50.78,75.84 50.43,76.19C50.07,76.55 49.66,76.83 49.2,77.02C48.72,77.22 48.21,77.33 47.69,77.33C47.17,77.33 46.67,77.23 46.19,77.02C45.73,76.83 45.31,76.55 44.96,76.19C44.6,75.84 44.33,75.42 44.13,74.96C43.93,74.49 43.82,73.98 43.82,73.46C43.82,72.94 43.93,72.43 44.13,71.95C44.32,71.49 44.6,71.08 44.96,70.72C45.31,70.37 45.73,70.09 46.19,69.89C46.67,69.69 47.17,69.59 47.69,69.59C48.21,69.59 48.72,69.69 49.2,69.89C49.66,70.09 50.07,70.37 50.43,70.72C50.78,71.08 51.06,71.49 51.26,71.95C51.46,72.43 51.56,72.94 51.56,73.46ZM50.9,73.46C50.9,71.68 49.46,70.25 47.69,70.25C45.92,70.25 44.48,71.69 44.48,73.46C44.48,75.23 45.92,76.67 47.69,76.67C49.46,76.67 50.9,75.23 50.9,73.46Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M63.06,86.36C63.06,87.72 61.96,88.83 60.59,88.83C59.23,88.83 58.12,87.72 58.12,86.36C58.12,84.99 59.23,83.89 60.59,83.89C61.96,83.89 63.06,84.99 63.06,86.36ZM60.18,87.95V84.76C59.48,84.94 58.94,85.59 58.94,86.36C58.94,87.12 59.48,87.77 60.18,87.95M62.24,86.36C62.24,85.59 61.7,84.95 61,84.76V87.95C61.7,87.77 62.24,87.12 62.24,86.35"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M64.46,86.36C64.46,86.88 64.36,87.38 64.15,87.86C63.96,88.32 63.68,88.74 63.32,89.09C62.97,89.45 62.55,89.72 62.09,89.92C61.62,90.12 61.11,90.23 60.59,90.23C60.07,90.23 59.56,90.12 59.08,89.92C58.62,89.73 58.21,89.45 57.85,89.09C57.5,88.74 57.22,88.32 57.02,87.86C56.82,87.38 56.72,86.88 56.72,86.36C56.72,85.84 56.82,85.33 57.02,84.85C57.22,84.39 57.5,83.98 57.85,83.62C58.21,83.27 58.62,82.99 59.08,82.79C59.56,82.59 60.07,82.49 60.59,82.49C61.11,82.49 61.62,82.59 62.09,82.79C62.55,82.99 62.97,83.27 63.32,83.62C63.68,83.98 63.95,84.39 64.15,84.85C64.35,85.33 64.46,85.84 64.46,86.36ZM63.8,86.36C63.8,84.58 62.36,83.15 60.59,83.15C58.82,83.15 57.38,84.59 57.38,86.36C57.38,88.13 58.82,89.57 60.59,89.57C62.36,89.57 63.8,88.13 63.8,86.36Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M46.4,48.98C46.63,48.98 46.82,48.8 46.82,48.57V46.76C46.82,46.53 46.63,46.35 46.4,46.35C46.17,46.35 45.99,46.54 45.99,46.76V48.57C45.99,48.8 46.17,48.98 46.4,48.98Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M96.68,-8.78H43.8V44.09H96.68V-8.78Z"/>
<group>
<clip-path
android:pathData="M96.68,-8.78H43.8V44.09H96.68V-8.78Z"/>
<path
android:pathData="M77.38,47.67C77.38,48.19 77.28,48.69 77.07,49.17C76.88,49.63 76.6,50.04 76.24,50.4C75.89,50.75 75.48,51.03 75.02,51.23C74.54,51.43 74.03,51.53 73.51,51.53C72.99,51.53 72.48,51.43 72.01,51.23C71.55,51.03 71.13,50.75 70.78,50.4C70.42,50.04 70.14,49.63 69.95,49.17C69.75,48.69 69.64,48.19 69.64,47.67C69.64,47.14 69.74,46.64 69.95,46.16C70.14,45.7 70.42,45.29 70.78,44.93C71.13,44.58 71.55,44.3 72.01,44.1C72.48,43.9 72.99,43.8 73.51,43.8C74.03,43.8 74.54,43.9 75.02,44.1C75.48,44.3 75.89,44.58 76.24,44.93C76.6,45.29 76.88,45.7 77.07,46.16C77.28,46.64 77.38,47.14 77.38,47.67ZM76.72,47.67C76.72,45.89 75.28,44.46 73.51,44.46C71.74,44.46 70.3,45.89 70.3,47.67C70.3,49.44 71.74,50.87 73.51,50.87C75.28,50.87 76.72,49.44 76.72,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,47.67C51.56,48.19 51.46,48.69 51.26,49.17C51.06,49.63 50.78,50.04 50.43,50.4C50.07,50.75 49.66,51.03 49.2,51.23C48.72,51.43 48.21,51.53 47.69,51.53C47.17,51.53 46.67,51.43 46.19,51.23C45.73,51.03 45.31,50.75 44.96,50.4C44.6,50.04 44.33,49.63 44.13,49.17C43.93,48.69 43.82,48.19 43.82,47.67C43.82,47.14 43.93,46.64 44.13,46.16C44.32,45.7 44.6,45.29 44.96,44.93C45.31,44.58 45.73,44.3 46.19,44.1C46.67,43.9 47.17,43.8 47.69,43.8C48.21,43.8 48.72,43.9 49.2,44.1C49.66,44.3 50.07,44.58 50.43,44.93C50.78,45.29 51.06,45.7 51.26,46.16C51.46,46.64 51.56,47.14 51.56,47.67ZM50.9,47.67C50.9,45.89 49.46,44.46 47.69,44.46C45.92,44.46 44.48,45.89 44.48,47.67C44.48,49.44 45.92,50.87 47.69,50.87C49.46,50.87 50.9,49.44 50.9,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M47.69,18.1C47.46,18.1 47.28,18.28 47.28,18.51V22.66C47.28,22.89 47.46,23.07 47.69,23.07C47.92,23.07 48.11,22.88 48.11,22.66V18.51C48.11,18.28 47.92,18.1 47.69,18.1Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M48.98,19.27C48.75,19.27 48.57,19.45 48.57,19.68V21.49C48.57,21.72 48.75,21.9 48.98,21.9C49.21,21.9 49.4,21.71 49.4,21.49V19.68C49.4,19.45 49.21,19.27 48.98,19.27Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M46.4,21.9C46.63,21.9 46.82,21.71 46.82,21.49V19.68C46.82,19.45 46.63,19.27 46.4,19.27C46.17,19.27 45.99,19.45 45.99,19.68V21.49C45.99,21.72 46.17,21.9 46.4,21.9Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M90.28,7.69C90.28,8.21 90.17,8.71 89.97,9.19C89.78,9.65 89.5,10.07 89.14,10.42C88.79,10.78 88.37,11.05 87.91,11.25C87.43,11.45 86.93,11.56 86.41,11.56C85.88,11.56 85.38,11.45 84.9,11.25C84.44,11.06 84.03,10.78 83.67,10.42C83.32,10.07 83.04,9.65 82.84,9.19C82.64,8.71 82.54,8.21 82.54,7.69C82.54,7.17 82.64,6.66 82.84,6.18C83.04,5.72 83.32,5.31 83.67,4.95C84.03,4.6 84.44,4.32 84.9,4.12C85.38,3.92 85.88,3.82 86.41,3.82C86.93,3.82 87.43,3.92 87.91,4.12C88.37,4.32 88.79,4.6 89.14,4.95C89.5,5.31 89.77,5.72 89.97,6.18C90.17,6.66 90.28,7.17 90.28,7.69ZM89.62,7.69C89.62,5.91 88.18,4.48 86.41,4.48C84.64,4.48 83.2,5.92 83.2,7.69C83.2,9.46 84.64,10.9 86.41,10.9C88.18,10.9 89.62,9.46 89.62,7.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M87.38,7.07C87.03,7.07 86.76,7.35 86.76,7.69C86.76,8.03 87.04,8.3 87.38,8.3C87.71,8.3 87.99,8.03 87.99,7.69C87.99,7.35 87.71,7.07 87.38,7.07Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,5.81C85.59,5.81 85.32,6.09 85.32,6.43C85.32,6.77 85.59,7.05 85.93,7.05C86.27,7.05 86.55,6.77 86.55,6.43C86.55,6.09 86.27,5.81 85.93,5.81Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,8.33C85.59,8.33 85.32,8.6 85.32,8.94C85.32,9.28 85.59,9.56 85.93,9.56C86.27,9.56 86.55,9.28 86.55,8.94C86.55,8.6 86.27,8.33 85.93,8.33Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M73.51,18.11C72.15,18.11 71.04,19.22 71.04,20.58C71.04,21.95 72.15,23.05 73.51,23.05C74.87,23.05 75.98,21.95 75.98,20.58C75.98,19.22 74.87,18.11 73.51,18.11Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M77.38,20.58C77.38,21.1 77.28,21.61 77.07,22.09C76.88,22.55 76.6,22.96 76.24,23.32C75.89,23.67 75.48,23.95 75.02,24.15C74.54,24.35 74.03,24.45 73.51,24.45C72.99,24.45 72.48,24.35 72.01,24.15C71.55,23.95 71.13,23.67 70.78,23.32C70.42,22.96 70.14,22.55 69.95,22.09C69.75,21.61 69.64,21.1 69.64,20.58C69.64,20.06 69.74,19.55 69.95,19.08C70.14,18.62 70.42,18.2 70.78,17.85C71.13,17.49 71.55,17.22 72.01,17.02C72.48,16.82 72.99,16.71 73.51,16.71C74.03,16.71 74.54,16.81 75.02,17.02C75.48,17.21 75.89,17.49 76.24,17.85C76.6,18.2 76.88,18.62 77.07,19.08C77.28,19.55 77.38,20.06 77.38,20.58ZM76.72,20.58C76.72,18.81 75.28,17.37 73.51,17.37C71.74,17.37 70.3,18.81 70.3,20.58C70.3,22.35 71.74,23.79 73.51,23.79C75.28,23.79 76.72,22.35 76.72,20.58Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M90.28,33.48C90.28,34 90.17,34.51 89.97,34.99C89.78,35.45 89.5,35.86 89.14,36.21C88.79,36.57 88.37,36.85 87.91,37.04C87.43,37.24 86.93,37.35 86.41,37.35C85.88,37.35 85.38,37.25 84.9,37.04C84.44,36.85 84.03,36.57 83.67,36.21C83.32,35.86 83.04,35.45 82.84,34.99C82.64,34.51 82.54,34 82.54,33.48C82.54,32.96 82.64,32.45 82.84,31.97C83.04,31.51 83.32,31.1 83.67,30.75C84.03,30.39 84.44,30.11 84.9,29.92C85.38,29.72 85.88,29.61 86.41,29.61C86.93,29.61 87.43,29.71 87.91,29.92C88.37,30.11 88.79,30.39 89.14,30.75C89.5,31.1 89.77,31.51 89.97,31.97C90.17,32.45 90.28,32.96 90.28,33.48ZM89.62,33.48C89.62,31.71 88.18,30.27 86.41,30.27C84.64,30.27 83.2,31.71 83.2,33.48C83.2,35.25 84.64,36.69 86.41,36.69C88.18,36.69 89.62,35.25 89.62,33.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M87.38,32.86C87.03,32.86 86.76,33.14 86.76,33.48C86.76,33.82 87.04,34.1 87.38,34.1C87.71,34.1 87.99,33.82 87.99,33.48C87.99,33.14 87.71,32.86 87.38,32.86Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,31.61C85.59,31.61 85.32,31.88 85.32,32.22C85.32,32.56 85.59,32.84 85.93,32.84C86.27,32.84 86.55,32.56 86.55,32.22C86.55,31.88 86.27,31.61 85.93,31.61Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M85.93,34.12C85.59,34.12 85.32,34.4 85.32,34.74C85.32,35.08 85.59,35.35 85.93,35.35C86.27,35.35 86.55,35.08 86.55,34.74C86.55,34.4 86.27,34.12 85.93,34.12Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M63.06,7.69C63.06,9.05 61.96,10.16 60.59,10.16C59.23,10.16 58.12,9.05 58.12,7.69C58.12,6.32 59.23,5.22 60.59,5.22C61.96,5.22 63.06,6.32 63.06,7.69ZM60.18,9.28V6.09C59.48,6.28 58.94,6.92 58.94,7.69C58.94,8.45 59.48,9.1 60.18,9.28M62.24,7.69C62.24,6.92 61.7,6.28 61,6.09V9.28C61.7,9.1 62.24,8.45 62.24,7.68"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M64.46,7.69C64.46,8.21 64.36,8.71 64.15,9.19C63.96,9.65 63.68,10.07 63.32,10.42C62.97,10.78 62.55,11.05 62.09,11.25C61.62,11.45 61.11,11.56 60.59,11.56C60.07,11.56 59.56,11.45 59.08,11.25C58.62,11.06 58.21,10.78 57.85,10.42C57.5,10.07 57.22,9.65 57.02,9.19C56.82,8.71 56.72,8.21 56.72,7.69C56.72,7.17 56.82,6.66 57.02,6.18C57.22,5.72 57.5,5.31 57.85,4.95C58.21,4.6 58.62,4.32 59.08,4.12C59.56,3.92 60.07,3.82 60.59,3.82C61.11,3.82 61.62,3.92 62.09,4.12C62.55,4.32 62.97,4.6 63.32,4.95C63.68,5.31 63.95,5.72 64.15,6.18C64.35,6.66 64.46,7.17 64.46,7.69ZM63.8,7.69C63.8,5.91 62.36,4.48 60.59,4.48C58.82,4.48 57.38,5.92 57.38,7.69C57.38,9.46 58.82,10.9 60.59,10.9C62.36,10.9 63.8,9.46 63.8,7.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M51.56,20.58C51.56,21.1 51.46,21.61 51.26,22.09C51.06,22.55 50.78,22.96 50.43,23.32C50.07,23.67 49.66,23.95 49.2,24.15C48.72,24.35 48.21,24.45 47.69,24.45C47.17,24.45 46.67,24.35 46.19,24.15C45.73,23.95 45.31,23.67 44.96,23.32C44.6,22.96 44.33,22.55 44.13,22.09C43.93,21.61 43.82,21.1 43.82,20.58C43.82,20.06 43.93,19.55 44.13,19.08C44.32,18.62 44.6,18.2 44.96,17.85C45.31,17.49 45.73,17.22 46.19,17.02C46.67,16.82 47.17,16.71 47.69,16.71C48.21,16.71 48.72,16.81 49.2,17.02C49.66,17.21 50.07,17.49 50.43,17.85C50.78,18.2 51.06,18.62 51.26,19.08C51.46,19.55 51.56,20.06 51.56,20.58ZM50.9,20.58C50.9,18.81 49.46,17.37 47.69,17.37C45.92,17.37 44.48,18.81 44.48,20.58C44.48,22.35 45.92,23.79 47.69,23.79C49.46,23.79 50.9,22.35 50.9,20.58Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M63.06,33.48C63.06,34.84 61.96,35.95 60.59,35.95C59.23,35.95 58.12,34.84 58.12,33.48C58.12,32.12 59.23,31.01 60.59,31.01C61.96,31.01 63.06,32.12 63.06,33.48ZM60.18,35.08V31.89C59.48,32.07 58.94,32.72 58.94,33.48C58.94,34.25 59.48,34.89 60.18,35.08M62.24,33.48C62.24,32.71 61.7,32.07 61,31.88V35.07C61.7,34.89 62.24,34.24 62.24,33.48"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M64.46,33.48C64.46,34 64.36,34.51 64.15,34.99C63.96,35.45 63.68,35.86 63.32,36.21C62.97,36.57 62.55,36.85 62.09,37.04C61.62,37.24 61.11,37.35 60.59,37.35C60.07,37.35 59.56,37.25 59.08,37.04C58.62,36.85 58.21,36.57 57.85,36.21C57.5,35.86 57.22,35.45 57.02,34.99C56.82,34.51 56.72,34 56.72,33.48C56.72,32.96 56.82,32.45 57.02,31.97C57.22,31.51 57.5,31.1 57.85,30.75C58.21,30.39 58.62,30.11 59.08,29.92C59.56,29.72 60.07,29.61 60.59,29.61C61.11,29.61 61.62,29.71 62.09,29.92C62.55,30.11 62.97,30.39 63.32,30.75C63.68,31.1 63.95,31.51 64.15,31.97C64.35,32.45 64.46,32.96 64.46,33.48ZM63.8,33.48C63.8,31.71 62.36,30.27 60.59,30.27C58.82,30.27 57.38,31.71 57.38,33.48C57.38,35.25 58.82,36.69 60.59,36.69C62.36,36.69 63.8,35.25 63.8,33.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M43.81,96.97H-9.07V149.85H43.81V96.97Z"/>
<group>
<clip-path
android:pathData="M43.81,96.97H-9.07V149.85H43.81V96.97Z"/>
<path
android:pathData="M20.63,98.07C19.27,98.07 18.17,99.18 18.17,100.54C18.17,101.91 19.27,103.01 20.63,103.01C22,103.01 23.1,101.91 23.1,100.54C23.1,99.18 22,98.07 20.63,98.07Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M24.5,100.54C24.5,101.06 24.4,101.57 24.2,102.05C24,102.51 23.72,102.92 23.37,103.28C23.01,103.63 22.6,103.91 22.14,104.11C21.66,104.31 21.16,104.41 20.63,104.41C20.11,104.41 19.61,104.31 19.13,104.11C18.67,103.91 18.25,103.63 17.9,103.28C17.55,102.92 17.27,102.51 17.07,102.05C16.87,101.57 16.76,101.06 16.76,100.54C16.76,100.02 16.87,99.51 17.07,99.04C17.26,98.58 17.55,98.16 17.9,97.81C18.25,97.45 18.67,97.18 19.13,96.98C19.61,96.78 20.11,96.67 20.63,96.67C21.16,96.67 21.66,96.77 22.14,96.98C22.6,97.17 23.01,97.45 23.37,97.81C23.72,98.16 24,98.58 24.2,99.04C24.4,99.51 24.5,100.02 24.5,100.54ZM23.85,100.54C23.85,98.77 22.41,97.33 20.64,97.33C18.87,97.33 17.43,98.77 17.43,100.54C17.43,102.31 18.87,103.75 20.64,103.75C22.41,103.75 23.85,102.31 23.85,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M43.81,44.09H-9.07V96.97H43.81V44.09Z"/>
<group>
<clip-path
android:pathData="M43.81,44.09H-9.07V96.97H43.81V44.09Z"/>
<path
android:pathData="M24.5,100.54C24.5,101.06 24.4,101.57 24.2,102.05C24,102.51 23.72,102.92 23.37,103.28C23.01,103.63 22.6,103.91 22.14,104.11C21.66,104.31 21.16,104.41 20.63,104.41C20.11,104.41 19.61,104.31 19.13,104.11C18.67,103.91 18.25,103.63 17.9,103.28C17.55,102.92 17.27,102.51 17.07,102.05C16.87,101.57 16.76,101.06 16.76,100.54C16.76,100.02 16.87,99.51 17.07,99.04C17.26,98.58 17.55,98.16 17.9,97.81C18.25,97.45 18.67,97.18 19.13,96.98C19.61,96.78 20.11,96.67 20.63,96.67C21.16,96.67 21.66,96.77 22.14,96.98C22.6,97.17 23.01,97.45 23.37,97.81C23.72,98.16 24,98.58 24.2,99.04C24.4,99.51 24.5,100.02 24.5,100.54ZM23.85,100.54C23.85,98.77 22.41,97.33 20.64,97.33C18.87,97.33 17.43,98.77 17.43,100.54C17.43,102.31 18.87,103.75 20.64,103.75C22.41,103.75 23.85,102.31 23.85,100.54Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M20.63,45.2C19.27,45.2 18.17,46.3 18.17,47.67C18.17,49.03 19.27,50.13 20.63,50.13C22,50.13 23.1,49.03 23.1,47.67C23.1,46.3 22,45.2 20.63,45.2Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M24.5,47.67C24.5,48.19 24.4,48.69 24.2,49.17C24,49.63 23.72,50.04 23.37,50.4C23.01,50.75 22.6,51.03 22.14,51.23C21.66,51.43 21.16,51.53 20.63,51.53C20.11,51.53 19.61,51.43 19.13,51.23C18.67,51.03 18.25,50.75 17.9,50.4C17.55,50.04 17.27,49.63 17.07,49.17C16.87,48.69 16.76,48.19 16.76,47.67C16.76,47.14 16.87,46.64 17.07,46.16C17.26,45.7 17.55,45.29 17.9,44.93C18.25,44.58 18.67,44.3 19.13,44.1C19.61,43.9 20.11,43.8 20.63,43.8C21.16,43.8 21.66,43.9 22.14,44.1C22.6,44.3 23.01,44.58 23.37,44.93C23.72,45.29 24,45.7 24.2,46.16C24.4,46.64 24.5,47.14 24.5,47.67ZM23.85,47.67C23.85,45.89 22.41,44.46 20.64,44.46C18.87,44.46 17.43,45.89 17.43,47.67C17.43,49.44 18.87,50.87 20.64,50.87C22.41,50.87 23.85,49.44 23.85,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M37.4,60.56C37.4,61.08 37.3,61.59 37.1,62.07C36.9,62.53 36.62,62.94 36.27,63.3C35.91,63.65 35.5,63.93 35.04,64.13C34.56,64.33 34.05,64.43 33.53,64.43C33.01,64.43 32.5,64.33 32.03,64.13C31.57,63.93 31.15,63.65 30.8,63.3C30.44,62.94 30.17,62.53 29.97,62.07C29.77,61.59 29.66,61.08 29.66,60.56C29.66,60.04 29.76,59.54 29.97,59.06C30.16,58.6 30.44,58.18 30.8,57.83C31.15,57.47 31.57,57.2 32.03,57C32.5,56.8 33.01,56.69 33.53,56.69C34.05,56.69 34.56,56.8 35.04,57C35.5,57.19 35.91,57.47 36.27,57.83C36.62,58.18 36.9,58.6 37.1,59.06C37.3,59.54 37.4,60.04 37.4,60.56ZM36.74,60.56C36.74,58.79 35.3,57.35 33.53,57.35C31.76,57.35 30.32,58.79 30.32,60.56C30.32,62.33 31.76,63.77 33.53,63.77C35.3,63.77 36.74,62.33 36.74,60.56Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M34.5,59.95C34.16,59.95 33.88,60.22 33.88,60.56C33.88,60.9 34.16,61.18 34.5,61.18C34.84,61.18 35.12,60.9 35.12,60.56C35.12,60.22 34.84,59.95 34.5,59.95Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,58.69C32.71,58.69 32.44,58.97 32.44,59.31C32.44,59.65 32.72,59.92 33.06,59.92C33.4,59.92 33.67,59.65 33.67,59.31C33.67,58.97 33.4,58.69 33.06,58.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,61.2C32.71,61.2 32.44,61.48 32.44,61.82C32.44,62.16 32.72,62.43 33.06,62.43C33.4,62.43 33.67,62.16 33.67,61.82C33.67,61.48 33.4,61.2 33.06,61.2Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M20.63,70.99C19.27,70.99 18.17,72.09 18.17,73.46C18.17,74.82 19.27,75.93 20.63,75.93C22,75.93 23.1,74.82 23.1,73.46C23.1,72.09 22,70.99 20.63,70.99Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M24.5,73.46C24.5,73.98 24.4,74.49 24.2,74.96C24,75.42 23.72,75.84 23.37,76.19C23.01,76.55 22.6,76.83 22.14,77.02C21.66,77.22 21.16,77.33 20.63,77.33C20.11,77.33 19.61,77.23 19.13,77.02C18.67,76.83 18.25,76.55 17.9,76.19C17.55,75.84 17.27,75.42 17.07,74.96C16.87,74.49 16.76,73.98 16.76,73.46C16.76,72.94 16.87,72.43 17.07,71.95C17.26,71.49 17.55,71.08 17.9,70.72C18.25,70.37 18.67,70.09 19.13,69.89C19.61,69.69 20.11,69.59 20.63,69.59C21.16,69.59 21.66,69.69 22.14,69.89C22.6,70.09 23.01,70.37 23.37,70.72C23.72,71.08 24,71.49 24.2,71.95C24.4,72.43 24.5,72.94 24.5,73.46ZM23.85,73.46C23.85,71.68 22.41,70.25 20.64,70.25C18.87,70.25 17.43,71.69 17.43,73.46C17.43,75.23 18.87,76.67 20.64,76.67C22.41,76.67 23.85,75.23 23.85,73.46Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M37.4,86.36C37.4,86.88 37.3,87.38 37.1,87.86C36.9,88.32 36.62,88.74 36.27,89.09C35.91,89.45 35.5,89.72 35.04,89.92C34.56,90.12 34.05,90.23 33.53,90.23C33.01,90.23 32.5,90.12 32.03,89.92C31.57,89.73 31.15,89.45 30.8,89.09C30.44,88.74 30.17,88.32 29.97,87.86C29.77,87.38 29.66,86.88 29.66,86.36C29.66,85.84 29.76,85.33 29.97,84.85C30.16,84.39 30.44,83.98 30.8,83.62C31.15,83.27 31.57,82.99 32.03,82.79C32.5,82.59 33.01,82.49 33.53,82.49C34.05,82.49 34.56,82.59 35.04,82.79C35.5,82.99 35.91,83.27 36.27,83.62C36.62,83.98 36.9,84.39 37.1,84.85C37.3,85.33 37.4,85.84 37.4,86.36ZM36.74,86.36C36.74,84.58 35.3,83.15 33.53,83.15C31.76,83.15 30.32,84.59 30.32,86.36C30.32,88.13 31.76,89.57 33.53,89.57C35.3,89.57 36.74,88.13 36.74,86.36Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M34.5,85.74C34.16,85.74 33.88,86.01 33.88,86.35C33.88,86.69 34.16,86.97 34.5,86.97C34.84,86.97 35.12,86.69 35.12,86.35C35.12,86.01 34.84,85.74 34.5,85.74Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,84.48C32.71,84.48 32.44,84.76 32.44,85.1C32.44,85.44 32.72,85.72 33.06,85.72C33.4,85.72 33.67,85.44 33.67,85.1C33.67,84.76 33.4,84.48 33.06,84.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,86.99C32.71,86.99 32.44,87.27 32.44,87.61C32.44,87.95 32.72,88.23 33.06,88.23C33.4,88.23 33.67,87.95 33.67,87.61C33.67,87.27 33.4,86.99 33.06,86.99Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M10.18,60.56C10.18,61.93 9.08,63.03 7.71,63.03C6.35,63.03 5.25,61.93 5.25,60.56C5.25,59.2 6.35,58.09 7.71,58.09C9.08,58.09 10.18,59.2 10.18,60.56ZM7.3,62.16V58.97C6.6,59.15 6.07,59.8 6.07,60.56C6.07,61.33 6.6,61.97 7.3,62.16M9.36,60.56C9.36,59.8 8.83,59.15 8.13,58.97V62.16C8.83,61.97 9.36,61.33 9.36,60.56"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M11.58,60.56C11.58,61.08 11.48,61.59 11.28,62.07C11.08,62.53 10.8,62.94 10.45,63.3C10.09,63.65 9.68,63.93 9.22,64.13C8.74,64.33 8.23,64.43 7.71,64.43C7.19,64.43 6.68,64.33 6.21,64.13C5.75,63.93 5.33,63.65 4.98,63.3C4.62,62.94 4.34,62.53 4.15,62.07C3.95,61.59 3.84,61.08 3.84,60.56C3.84,60.04 3.94,59.54 4.15,59.06C4.34,58.6 4.62,58.18 4.98,57.83C5.33,57.47 5.75,57.2 6.21,57C6.68,56.8 7.19,56.69 7.71,56.69C8.23,56.69 8.74,56.8 9.22,57C9.68,57.19 10.09,57.47 10.45,57.83C10.8,58.18 11.08,58.6 11.28,59.06C11.48,59.54 11.58,60.04 11.58,60.56ZM10.92,60.56C10.92,58.79 9.49,57.35 7.71,57.35C5.94,57.35 4.51,58.79 4.51,60.56C4.51,62.33 5.94,63.77 7.71,63.77C9.49,63.77 10.92,62.33 10.92,60.56Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M10.18,86.36C10.18,87.72 9.08,88.83 7.71,88.83C6.35,88.83 5.25,87.72 5.25,86.36C5.25,84.99 6.35,83.89 7.71,83.89C9.08,83.89 10.18,84.99 10.18,86.36ZM7.3,87.95V84.76C6.6,84.94 6.07,85.59 6.07,86.36C6.07,87.12 6.6,87.77 7.3,87.95M9.36,86.36C9.36,85.59 8.83,84.95 8.13,84.76V87.95C8.83,87.77 9.36,87.12 9.36,86.35"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M11.58,86.36C11.58,86.88 11.48,87.38 11.28,87.86C11.08,88.32 10.8,88.74 10.45,89.09C10.09,89.45 9.68,89.72 9.22,89.92C8.74,90.12 8.23,90.23 7.71,90.23C7.19,90.23 6.68,90.12 6.21,89.92C5.75,89.73 5.33,89.45 4.98,89.09C4.62,88.74 4.34,88.32 4.15,87.86C3.95,87.38 3.84,86.88 3.84,86.36C3.84,85.84 3.94,85.33 4.15,84.85C4.34,84.39 4.62,83.98 4.98,83.62C5.33,83.27 5.75,82.99 6.21,82.79C6.68,82.59 7.19,82.49 7.71,82.49C8.23,82.49 8.74,82.59 9.22,82.79C9.68,82.99 10.09,83.27 10.45,83.62C10.8,83.98 11.08,84.39 11.28,84.85C11.48,85.33 11.58,85.84 11.58,86.36ZM10.92,86.36C10.92,84.58 9.49,83.15 7.71,83.15C5.94,83.15 4.51,84.59 4.51,86.36C4.51,88.13 5.94,89.57 7.71,89.57C9.49,89.57 10.92,88.13 10.92,86.36Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
<group>
<clip-path
android:pathData="M43.81,-8.78H-9.07V44.09H43.81V-8.78Z"/>
<group>
<clip-path
android:pathData="M43.81,-8.78H-9.07V44.09H43.81V-8.78Z"/>
<path
android:pathData="M24.5,47.67C24.5,48.19 24.4,48.69 24.2,49.17C24,49.63 23.72,50.04 23.37,50.4C23.01,50.75 22.6,51.03 22.14,51.23C21.66,51.43 21.16,51.53 20.63,51.53C20.11,51.53 19.61,51.43 19.13,51.23C18.67,51.03 18.25,50.75 17.9,50.4C17.55,50.04 17.27,49.63 17.07,49.17C16.87,48.69 16.76,48.19 16.76,47.67C16.76,47.14 16.87,46.64 17.07,46.16C17.26,45.7 17.55,45.29 17.9,44.93C18.25,44.58 18.67,44.3 19.13,44.1C19.61,43.9 20.11,43.8 20.63,43.8C21.16,43.8 21.66,43.9 22.14,44.1C22.6,44.3 23.01,44.58 23.37,44.93C23.72,45.29 24,45.7 24.2,46.16C24.4,46.64 24.5,47.14 24.5,47.67ZM23.85,47.67C23.85,45.89 22.41,44.46 20.64,44.46C18.87,44.46 17.43,45.89 17.43,47.67C17.43,49.44 18.87,50.87 20.64,50.87C22.41,50.87 23.85,49.44 23.85,47.67Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M37.4,7.69C37.4,8.21 37.3,8.71 37.1,9.19C36.9,9.65 36.62,10.07 36.27,10.42C35.91,10.78 35.5,11.05 35.04,11.25C34.56,11.45 34.05,11.56 33.53,11.56C33.01,11.56 32.5,11.45 32.03,11.25C31.57,11.06 31.15,10.78 30.8,10.42C30.44,10.07 30.17,9.65 29.97,9.19C29.77,8.71 29.66,8.21 29.66,7.69C29.66,7.17 29.76,6.66 29.97,6.18C30.16,5.72 30.44,5.31 30.8,4.95C31.15,4.6 31.57,4.32 32.03,4.12C32.5,3.92 33.01,3.82 33.53,3.82C34.05,3.82 34.56,3.92 35.04,4.12C35.5,4.32 35.91,4.6 36.27,4.95C36.62,5.31 36.9,5.72 37.1,6.18C37.3,6.66 37.4,7.17 37.4,7.69ZM36.74,7.69C36.74,5.91 35.3,4.48 33.53,4.48C31.76,4.48 30.32,5.92 30.32,7.69C30.32,9.46 31.76,10.9 33.53,10.9C35.3,10.9 36.74,9.46 36.74,7.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M34.5,7.07C34.16,7.07 33.88,7.35 33.88,7.69C33.88,8.03 34.16,8.3 34.5,8.3C34.84,8.3 35.12,8.03 35.12,7.69C35.12,7.35 34.84,7.07 34.5,7.07Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,5.81C32.71,5.81 32.44,6.09 32.44,6.43C32.44,6.77 32.72,7.05 33.06,7.05C33.4,7.05 33.67,6.77 33.67,6.43C33.67,6.09 33.4,5.81 33.06,5.81Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,8.33C32.71,8.33 32.44,8.6 32.44,8.94C32.44,9.28 32.72,9.56 33.06,9.56C33.4,9.56 33.67,9.28 33.67,8.94C33.67,8.6 33.4,8.33 33.06,8.33Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M20.63,18.11C19.27,18.11 18.17,19.22 18.17,20.58C18.17,21.95 19.27,23.05 20.63,23.05C22,23.05 23.1,21.95 23.1,20.58C23.1,19.22 22,18.11 20.63,18.11Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M24.5,20.58C24.5,21.1 24.4,21.61 24.2,22.09C24,22.55 23.72,22.96 23.37,23.32C23.01,23.67 22.6,23.95 22.14,24.15C21.66,24.35 21.16,24.45 20.63,24.45C20.11,24.45 19.61,24.35 19.13,24.15C18.67,23.95 18.25,23.67 17.9,23.32C17.55,22.96 17.27,22.55 17.07,22.09C16.87,21.61 16.76,21.1 16.76,20.58C16.76,20.06 16.87,19.55 17.07,19.08C17.26,18.62 17.55,18.2 17.9,17.85C18.25,17.49 18.67,17.22 19.13,17.02C19.61,16.82 20.11,16.71 20.63,16.71C21.16,16.71 21.66,16.81 22.14,17.02C22.6,17.21 23.01,17.49 23.37,17.85C23.72,18.2 24,18.62 24.2,19.08C24.4,19.55 24.5,20.06 24.5,20.58ZM23.85,20.58C23.85,18.81 22.41,17.37 20.64,17.37C18.87,17.37 17.43,18.81 17.43,20.58C17.43,22.35 18.87,23.79 20.64,23.79C22.41,23.79 23.85,22.35 23.85,20.58Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M37.4,33.48C37.4,34 37.3,34.51 37.1,34.99C36.9,35.45 36.62,35.86 36.27,36.21C35.91,36.57 35.5,36.85 35.04,37.04C34.56,37.24 34.05,37.35 33.53,37.35C33.01,37.35 32.5,37.25 32.03,37.04C31.57,36.85 31.15,36.57 30.8,36.21C30.44,35.86 30.17,35.45 29.97,34.99C29.77,34.51 29.66,34 29.66,33.48C29.66,32.96 29.76,32.45 29.97,31.97C30.16,31.51 30.44,31.1 30.8,30.75C31.15,30.39 31.57,30.11 32.03,29.92C32.5,29.72 33.01,29.61 33.53,29.61C34.05,29.61 34.56,29.71 35.04,29.92C35.5,30.11 35.91,30.39 36.27,30.75C36.62,31.1 36.9,31.51 37.1,31.97C37.3,32.45 37.4,32.96 37.4,33.48ZM36.74,33.48C36.74,31.71 35.3,30.27 33.53,30.27C31.76,30.27 30.32,31.71 30.32,33.48C30.32,35.25 31.76,36.69 33.53,36.69C35.3,36.69 36.74,35.25 36.74,33.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M34.5,32.86C34.16,32.86 33.88,33.14 33.88,33.48C33.88,33.82 34.16,34.1 34.5,34.1C34.84,34.1 35.12,33.82 35.12,33.48C35.12,33.14 34.84,32.86 34.5,32.86Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,31.61C32.71,31.61 32.44,31.88 32.44,32.22C32.44,32.56 32.72,32.84 33.06,32.84C33.4,32.84 33.67,32.56 33.67,32.22C33.67,31.88 33.4,31.61 33.06,31.61Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M33.06,34.12C32.71,34.12 32.44,34.4 32.44,34.74C32.44,35.08 32.72,35.35 33.06,35.35C33.4,35.35 33.67,35.08 33.67,34.74C33.67,34.4 33.4,34.12 33.06,34.12Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M10.18,7.69C10.18,9.05 9.08,10.16 7.71,10.16C6.35,10.16 5.25,9.05 5.25,7.69C5.25,6.32 6.35,5.22 7.71,5.22C9.08,5.22 10.18,6.32 10.18,7.69ZM7.3,9.28V6.09C6.6,6.28 6.07,6.92 6.07,7.69C6.07,8.45 6.6,9.1 7.3,9.28M9.36,7.69C9.36,6.92 8.83,6.28 8.13,6.09V9.28C8.83,9.1 9.36,8.45 9.36,7.68"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M11.58,7.69C11.58,8.21 11.48,8.71 11.28,9.19C11.08,9.65 10.8,10.07 10.45,10.42C10.09,10.78 9.68,11.05 9.22,11.25C8.74,11.45 8.23,11.56 7.71,11.56C7.19,11.56 6.68,11.45 6.21,11.25C5.75,11.06 5.33,10.78 4.98,10.42C4.62,10.07 4.34,9.65 4.15,9.19C3.95,8.71 3.84,8.21 3.84,7.69C3.84,7.17 3.94,6.66 4.15,6.18C4.34,5.72 4.62,5.31 4.98,4.95C5.33,4.6 5.75,4.32 6.21,4.12C6.68,3.92 7.19,3.82 7.71,3.82C8.23,3.82 8.74,3.92 9.22,4.12C9.68,4.32 10.09,4.6 10.45,4.95C10.8,5.31 11.08,5.72 11.28,6.18C11.48,6.66 11.58,7.17 11.58,7.69ZM10.92,7.69C10.92,5.91 9.49,4.48 7.71,4.48C5.94,4.48 4.51,5.92 4.51,7.69C4.51,9.46 5.94,10.9 7.71,10.9C9.49,10.9 10.92,9.46 10.92,7.69Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M10.18,33.48C10.18,34.84 9.08,35.95 7.71,35.95C6.35,35.95 5.25,34.84 5.25,33.48C5.25,32.12 6.35,31.01 7.71,31.01C9.08,31.01 10.18,32.12 10.18,33.48ZM7.3,35.08V31.89C6.6,32.07 6.07,32.72 6.07,33.48C6.07,34.25 6.6,34.89 7.3,35.08M9.36,33.48C9.36,32.71 8.83,32.07 8.13,31.88V35.07C8.83,34.89 9.36,34.24 9.36,33.48"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
<path
android:pathData="M11.58,33.48C11.58,34 11.48,34.51 11.28,34.99C11.08,35.45 10.8,35.86 10.45,36.21C10.09,36.57 9.68,36.85 9.22,37.04C8.74,37.24 8.23,37.35 7.71,37.35C7.19,37.35 6.68,37.25 6.21,37.04C5.75,36.85 5.33,36.57 4.98,36.21C4.62,35.86 4.34,35.45 4.15,34.99C3.95,34.51 3.84,34 3.84,33.48C3.84,32.96 3.94,32.45 4.15,31.97C4.34,31.51 4.62,31.1 4.98,30.75C5.33,30.39 5.75,30.11 6.21,29.92C6.68,29.72 7.19,29.61 7.71,29.61C8.23,29.61 8.74,29.71 9.22,29.92C9.68,30.11 10.09,30.39 10.45,30.75C10.8,31.1 11.08,31.51 11.28,31.97C11.48,32.45 11.58,32.96 11.58,33.48ZM10.92,33.48C10.92,31.71 9.49,30.27 7.71,30.27C5.94,30.27 4.51,31.71 4.51,33.48C4.51,35.25 5.94,36.69 7.71,36.69C9.49,36.69 10.92,35.25 10.92,33.48Z"
android:strokeAlpha="0.2"
android:fillColor="#000000"
android:fillAlpha="0.2"/>
</group>
</group>
</group>
</group>
</group>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M73.92,44.43C74.82,44.43 75.43,45.1 75.43,46.02V54.54C75.43,55.46 74.82,56.13 73.92,56.13C73,56.13 72.41,55.46 72.41,54.54V46.02C72.41,45.1 73,44.43 73.92,44.43ZM73.92,61.55C72.82,61.55 71.95,60.68 71.95,59.58C71.95,58.51 72.82,57.64 73.92,57.64C75.02,57.64 75.89,58.51 75.89,59.58C75.89,60.68 75.02,61.55 73.92,61.55Z"
android:fillColor="#000000"/>
<path
android:pathData="M68.41,48.55C69.33,48.55 69.92,49.22 69.92,50.11V55.77C69.92,59.94 67.35,61.55 64.22,61.55C61.08,61.55 58.5,59.94 58.5,55.77V50.11C58.5,49.22 59.09,48.55 60.01,48.55C60.91,48.55 61.52,49.22 61.52,50.11V55.56C61.52,57.84 62.48,58.74 64.22,58.74C65.94,58.74 66.9,57.84 66.9,55.56V50.11C66.9,49.22 67.51,48.55 68.41,48.55Z"
android:fillColor="#000000"/>
<path
android:pathData="M49.94,52.01C49.94,52.85 50.81,53.16 52.47,53.54C54.78,54.1 56.98,54.69 56.98,57.53C56.98,60.3 54.93,61.55 51.99,61.55C49.56,61.55 47.79,60.71 46.97,59.73C46.33,58.97 46.41,58.3 47.02,57.71C47.79,56.97 48.46,57.28 48.89,57.66C49.58,58.3 50.43,58.97 52.07,58.97C53.29,58.97 54.06,58.56 54.06,57.74C54.06,56.92 53.24,56.64 51.09,56.05C48.97,55.46 47.08,54.9 47.08,52.36C47.08,49.52 49.38,48.35 51.94,48.35C53.4,48.35 55.06,48.73 56.08,49.83C56.52,50.27 56.85,50.88 56.08,51.72C55.32,52.52 54.75,52.29 54.22,51.88C53.73,51.52 52.88,50.91 51.6,50.91C50.73,50.91 49.94,51.19 49.94,52.01Z"
android:fillColor="#000000"/>
<path
android:pathData="M38.79,61.55C34.9,61.55 32.11,58.74 32.11,54.95C32.11,51.14 34.9,48.35 38.79,48.35C42.68,48.35 45.47,51.14 45.47,54.95C45.47,58.74 42.68,61.55 38.79,61.55ZM38.79,58.74C41.04,58.74 42.45,57.1 42.45,54.95C42.45,52.8 41.04,51.14 38.79,51.14C36.54,51.14 35.13,52.8 35.13,54.95C35.13,57.1 36.54,58.74 38.79,58.74Z"
android:fillColor="#000000"/>
<path
android:pathData="M86,54C86,71.67 71.67,86 54,86C36.33,86 22,71.67 22,54C22,36.33 36.33,22 54,22C71.67,22 86,36.33 86,54ZM25.2,54C25.2,69.91 38.09,82.8 54,82.8C69.91,82.8 82.8,69.91 82.8,54C82.8,38.09 69.91,25.2 54,25.2C38.09,25.2 25.2,38.09 25.2,54Z"
android:fillColor="#000000"/>
<path
android:pathData="M36.78,54.99C36.78,56.09 37.65,56.96 38.75,56.96C39.85,56.96 40.72,56.09 40.72,54.99C40.72,53.91 39.85,53.04 38.75,53.04C37.65,53.04 36.78,53.91 36.78,54.99Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
osu.Desktop/lazer.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -7,11 +7,12 @@
<authors>ppy Pty Ltd</authors> <authors>ppy Pty Ltd</authors>
<owners>Dean Herbert</owners> <owners>Dean Herbert</owners>
<projectUrl>https://osu.ppy.sh/</projectUrl> <projectUrl>https://osu.ppy.sh/</projectUrl>
<iconUrl>https://puu.sh/tYyXZ/9a01a5d1b0.ico</iconUrl> <iconUrl>https://github.com/ppy/osu/blob/master/assets/lazer-nuget.png?raw=true</iconUrl>
<icon>icon.png</icon>
<requireLicenseAcceptance>false</requireLicenseAcceptance> <requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>A free-to-win rhythm game. Rhythm is just a *click* away!</description> <description>A free-to-win rhythm game. Rhythm is just a *click* away!</description>
<releaseNotes>testing</releaseNotes> <releaseNotes>testing</releaseNotes>
<copyright>Copyright (c) 2022 ppy Pty Ltd</copyright> <copyright>Copyright (c) 2024 ppy Pty Ltd</copyright>
<language>en-AU</language> <language>en-AU</language>
</metadata> </metadata>
<files> <files>
@ -19,5 +20,6 @@
<file src="**.dll" target="lib\net45\"/> <file src="**.dll" target="lib\net45\"/>
<file src="**.config" target="lib\net45\"/> <file src="**.config" target="lib\net45\"/>
<file src="**.json" target="lib\net45\"/> <file src="**.json" target="lib\net45\"/>
<file src="icon.png" target=""/>
</files> </files>
</package> </package>

View File

@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
foreach (var stage in stages) foreach (var stage in stages)
{ {
for (int i = 0; i < stage.Columns.Count; i++) for (int i = 0; i < stage.Columns.Length; i++)
{ {
var obj = new Note { Column = i, StartTime = Time.Current + 2000 }; var obj = new Note { Column = i, StartTime = Time.Current + 2000 };
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
foreach (var stage in stages) foreach (var stage in stages)
{ {
for (int i = 0; i < stage.Columns.Count; i++) for (int i = 0; i < stage.Columns.Length; i++)
{ {
var obj = new HoldNote { Column = i, StartTime = Time.Current + 2000, Duration = 500 }; var obj = new HoldNote { Column = i, StartTime = Time.Current + 2000, Duration = 500 };
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());

View File

@ -17,7 +17,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Skinning.Argon namespace osu.Game.Rulesets.Mania.Skinning.Argon
{ {
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{ {
private const float judgement_y_position = 160; private const float judgement_y_position = 160;

View File

@ -4,8 +4,6 @@
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework; using osu.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -28,20 +26,21 @@ namespace osu.Game.Rulesets.Mania.UI
/// <summary> /// <summary>
/// All contents added to this <see cref="ColumnFlow{TContent}"/>. /// All contents added to this <see cref="ColumnFlow{TContent}"/>.
/// </summary> /// </summary>
public IReadOnlyList<TContent> Content => columns.Children.Select(c => c.Count == 0 ? null : (TContent)c.Child).ToList(); public TContent[] Content { get; }
private readonly FillFlowContainer<Container> columns; private readonly FillFlowContainer<Container<TContent>> columns;
private readonly StageDefinition stageDefinition; private readonly StageDefinition stageDefinition;
public ColumnFlow(StageDefinition stageDefinition) public ColumnFlow(StageDefinition stageDefinition)
{ {
this.stageDefinition = stageDefinition; this.stageDefinition = stageDefinition;
Content = new TContent[stageDefinition.Columns];
AutoSizeAxes = Axes.X; AutoSizeAxes = Axes.X;
Masking = true; Masking = true;
InternalChild = columns = new FillFlowContainer<Container> InternalChild = columns = new FillFlowContainer<Container<TContent>>
{ {
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X, AutoSizeAxes = Axes.X,
@ -49,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.UI
}; };
for (int i = 0; i < stageDefinition.Columns; i++) for (int i = 0; i < stageDefinition.Columns; i++)
columns.Add(new Container { RelativeSizeAxes = Axes.Y }); columns.Add(new Container<TContent> { RelativeSizeAxes = Axes.Y });
} }
private ISkinSource currentSkin; private ISkinSource currentSkin;
@ -102,7 +101,10 @@ namespace osu.Game.Rulesets.Mania.UI
/// </summary> /// </summary>
/// <param name="column">The index of the column to set the content of.</param> /// <param name="column">The index of the column to set the content of.</param>
/// <param name="content">The content.</param> /// <param name="content">The content.</param>
public void SetContentForColumn(int column, TContent content) => columns[column].Child = content; public void SetContentForColumn(int column, TContent content)
{
Content[column] = columns[column].Child = content;
}
private void updateMobileSizing() private void updateMobileSizing()
{ {

View File

@ -42,7 +42,16 @@ namespace osu.Game.Rulesets.Mania.UI
} }
} }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos)); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
foreach (var s in stages)
{
if (s.ReceivePositionalInputAt(screenSpacePos))
return true;
}
return false;
}
public ManiaPlayfield(List<StageDefinition> stageDefinitions) public ManiaPlayfield(List<StageDefinition> stageDefinitions)
{ {
@ -71,7 +80,7 @@ namespace osu.Game.Rulesets.Mania.UI
stages.Add(newStage); stages.Add(newStage);
AddNested(newStage); AddNested(newStage);
firstColumnIndex += newStage.Columns.Count; firstColumnIndex += newStage.Columns.Length;
} }
} }
@ -125,9 +134,9 @@ namespace osu.Game.Rulesets.Mania.UI
foreach (var stage in stages) foreach (var stage in stages)
{ {
if (index >= stage.Columns.Count) if (index >= stage.Columns.Length)
{ {
index -= stage.Columns.Count; index -= stage.Columns.Length;
continue; continue;
} }
@ -140,7 +149,7 @@ namespace osu.Game.Rulesets.Mania.UI
/// <summary> /// <summary>
/// Retrieves the total amount of columns across all stages in this playfield. /// Retrieves the total amount of columns across all stages in this playfield.
/// </summary> /// </summary>
public int TotalColumns => stages.Sum(s => s.Columns.Count); public int TotalColumns => stages.Sum(s => s.Columns.Length);
private Stage getStageByColumn(int column) private Stage getStageByColumn(int column)
{ {
@ -148,7 +157,7 @@ namespace osu.Game.Rulesets.Mania.UI
foreach (var stage in stages) foreach (var stage in stages)
{ {
sum += stage.Columns.Count; sum += stage.Columns.Length;
if (sum > column) if (sum > column)
return stage; return stage;
} }

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.UI
public const float HIT_TARGET_POSITION = 110; public const float HIT_TARGET_POSITION = 110;
public IReadOnlyList<Column> Columns => columnFlow.Content; public Column[] Columns => columnFlow.Content;
private readonly ColumnFlow<Column> columnFlow; private readonly ColumnFlow<Column> columnFlow;
private readonly JudgementContainer<DrawableManiaJudgement> judgements; private readonly JudgementContainer<DrawableManiaJudgement> judgements;
@ -45,7 +44,16 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly Drawable barLineContainer; private readonly Drawable barLineContainer;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos)); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
foreach (var c in Columns)
{
if (c.ReceivePositionalInputAt(screenSpacePos))
return true;
}
return false;
}
private readonly int firstColumnIndex; private readonly int firstColumnIndex;
@ -184,13 +192,13 @@ namespace osu.Game.Rulesets.Mania.UI
NewResult += OnNewResult; NewResult += OnNewResult;
} }
public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject); public override void Add(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Add(hitObject);
public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject); public override bool Remove(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Remove(hitObject);
public override void Add(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Add(h); public override void Add(DrawableHitObject h) => Columns[((ManiaHitObject)h.HitObject).Column - firstColumnIndex].Add(h);
public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h); public override bool Remove(DrawableHitObject h) => Columns[((ManiaHitObject)h.HitObject).Column - firstColumnIndex].Remove(h);
public void Add(BarLine barLine) => base.Add(barLine); public void Add(BarLine barLine) => base.Add(barLine);

View File

@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new PathControlPoint(new Vector2(-128, 0), PathType.LINEAR) // absolute position: (0, 128) new PathControlPoint(new Vector2(-128, 0), PathType.LINEAR) // absolute position: (0, 128)
} }
}, },
RepeatCount = 1 RepeatCount = 2
}; };
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
return slider; return slider;
@ -45,7 +45,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(slider); OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128))); Assert.That(slider.Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 0, 128))); Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 0, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X, 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{ {
new Vector2(), new Vector2(),
@ -62,7 +64,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(slider); OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128))); Assert.That(slider.Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128))); Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{ {
new Vector2(), new Vector2(),
@ -79,7 +83,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.FlipSliderInPlaceHorizontally(slider); OsuHitObjectGenerationUtils.FlipSliderInPlaceHorizontally(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(128, 128))); Assert.That(slider.Position, Is.EqualTo(new Vector2(128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(256, 128))); Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(256, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(256, 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{ {
new Vector2(), new Vector2(),

View File

@ -239,11 +239,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (Tracking.Value && Time.Current >= HitObject.StartTime) if (Tracking.Value && Time.Current >= HitObject.StartTime)
{ {
// keep the sliding sample playing at the current tracking position // keep the sliding sample playing at the current tracking position
if (!slidingSample.IsPlaying) if (!slidingSample.RequestedPlaying)
slidingSample.Play(); slidingSample.Play();
slidingSample.Balance.Value = CalculateSamplePlaybackBalance(CalculateDrawableRelativePosition(Ball)); slidingSample.Balance.Value = CalculateSamplePlaybackBalance(CalculateDrawableRelativePosition(Ball));
} }
else if (slidingSample.IsPlaying) else if (slidingSample.IsPlaying || slidingSample.RequestedPlaying)
slidingSample.Stop(); slidingSample.Stop();
} }
} }

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
public const double ANIM_DURATION = 150; public const double ANIM_DURATION = 150;
private const float default_tick_size = 16; public const float DEFAULT_TICK_SIZE = 16;
protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject;
@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
Masking = true, Masking = true,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Size = new Vector2(default_tick_size), Size = new Vector2(DEFAULT_TICK_SIZE),
BorderThickness = default_tick_size / 4, BorderThickness = DEFAULT_TICK_SIZE / 4,
BorderColour = Color4.White, BorderColour = Color4.White,
Child = new Box Child = new Box
{ {
@ -88,8 +88,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
break; break;
case ArmedState.Miss: case ArmedState.Miss:
this.FadeOut(ANIM_DURATION); this.FadeOut(ANIM_DURATION, Easing.OutQuint);
this.TransformBindableTo(AccentColour, Color4.Red, 0);
break; break;
case ArmedState.Hit: case ArmedState.Hit:

View File

@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
if (tracking.NewValue) if (tracking.NewValue)
{ {
if (!spinningSample.IsPlaying) if (!spinningSample.RequestedPlaying)
spinningSample.Play(); spinningSample.Play();
spinningSample.VolumeTo(1, 300); spinningSample.VolumeTo(1, 300);

View File

@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Objects
set set
{ {
repeatCount = value; repeatCount = value;
endPositionCache.Invalidate(); updateNestedPositions();
} }
} }
@ -165,7 +165,7 @@ namespace osu.Game.Rulesets.Osu.Objects
public Slider() public Slider()
{ {
SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples(); SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples();
Path.Version.ValueChanged += _ => endPositionCache.Invalidate(); Path.Version.ValueChanged += _ => updateNestedPositions();
} }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)

View File

@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu
base.ReloadMappings(realmKeyBindings); base.ReloadMappings(realmKeyBindings);
if (!AllowGameplayInputs) if (!AllowGameplayInputs)
KeyBindings = KeyBindings.Where(b => b.GetAction<OsuAction>() == OsuAction.Smoke).ToList(); KeyBindings = KeyBindings.Where(static b => b.GetAction<OsuAction>() == OsuAction.Smoke).ToList();
} }
} }
} }

View File

@ -28,6 +28,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Osu.Skinning.Argon; using osu.Game.Rulesets.Osu.Skinning.Argon;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Skinning.Legacy;
using osu.Game.Rulesets.Osu.Statistics; using osu.Game.Rulesets.Osu.Statistics;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
@ -254,6 +255,9 @@ namespace osu.Game.Rulesets.Osu
case ArgonSkin: case ArgonSkin:
return new OsuArgonSkinTransformer(skin); return new OsuArgonSkinTransformer(skin);
case TrianglesSkin:
return new OsuTrianglesSkinTransformer(skin);
} }
return null; return null;

View File

@ -16,7 +16,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Argon namespace osu.Game.Rulesets.Osu.Skinning.Argon
{ {
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{ {
private RingExplosion? ringExplosion; private RingExplosion? ringExplosion;

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonJudgementPieceSliderTickMiss : CompositeDrawable, IAnimatableJudgement
{
private readonly HitResult result;
private Circle piece = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public ArgonJudgementPieceSliderTickMiss(HitResult result)
{
this.result = result;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(piece = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Blending = BlendingParameters.Additive,
Colour = colours.ForHitResult(result),
Size = new Vector2(ArgonSliderScorePoint.SIZE)
});
}
public void PlayAnimation()
{
this.ScaleTo(1.4f);
this.ScaleTo(1f, 150, Easing.Out);
this.FadeOutFromOne(600);
}
public Drawable? GetAboveHitObjectsProxiedContent() => piece.CreateProxy();
}
}

View File

@ -16,14 +16,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{ {
private Bindable<Color4> accentColour = null!; private Bindable<Color4> accentColour = null!;
private const float size = 12; public const float SIZE = 12;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(DrawableHitObject hitObject) private void load(DrawableHitObject hitObject)
{ {
Masking = true; Masking = true;
Origin = Anchor.Centre; Origin = Anchor.Centre;
Size = new Vector2(size); Size = new Vector2(SIZE);
BorderThickness = 3; BorderThickness = 3;
BorderColour = Color4.White; BorderColour = Color4.White;
Child = new Box Child = new Box

View File

@ -19,11 +19,21 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
switch (lookup) switch (lookup)
{ {
case GameplaySkinComponentLookup<HitResult> resultComponent: case GameplaySkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
// This should eventually be moved to a skin setting, when supported. // This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && (resultComponent.Component == HitResult.Great || resultComponent.Component == HitResult.Perfect)) if (Skin is ArgonProSkin && (result == HitResult.Great || result == HitResult.Perfect))
return Drawable.Empty(); return Drawable.Empty();
return new ArgonJudgementPiece(resultComponent.Component); switch (result)
{
case HitResult.IgnoreMiss:
case HitResult.LargeTickMiss:
return new ArgonJudgementPieceSliderTickMiss(result);
default:
return new ArgonJudgementPiece(result);
}
case OsuSkinComponentLookup osuComponent: case OsuSkinComponentLookup osuComponent:
// TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries.

View File

@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public partial class DefaultJudgementPieceSliderTickMiss : CompositeDrawable, IAnimatableJudgement
{
private readonly HitResult result;
private Circle piece = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public DefaultJudgementPieceSliderTickMiss(HitResult result)
{
this.result = result;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(piece = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Blending = BlendingParameters.Additive,
Colour = colours.ForHitResult(result),
Size = new Vector2(DrawableSliderTick.DEFAULT_TICK_SIZE)
});
}
public void PlayAnimation()
{
this.ScaleTo(1.4f);
this.ScaleTo(1f, 150, Easing.Out);
this.FadeOutFromOne(600);
}
public Drawable? GetAboveHitObjectsProxiedContent() => piece.CreateProxy();
}
}

View File

@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public class OsuTrianglesSkinTransformer : SkinTransformer
{
public OsuTrianglesSkinTransformer(ISkin skin)
: base(skin)
{
}
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
switch (result)
{
case HitResult.IgnoreMiss:
case HitResult.LargeTickMiss:
// use argon judgement piece for new tick misses because i don't want to design another one for triangles.
return new DefaultJudgementPieceSliderTickMiss(result);
}
break;
}
return base.GetDrawableComponent(lookup);
}
}
}

View File

@ -17,7 +17,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{ {
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{ {
private RingExplosion? ringExplosion; private RingExplosion? ringExplosion;

View File

@ -16,7 +16,6 @@ using osu.Game.Replays;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.Timing;
@ -36,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.UI
public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager; public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager;
protected new TaikoPlayfieldAdjustmentContainer PlayfieldAdjustmentContainer => (TaikoPlayfieldAdjustmentContainer)base.PlayfieldAdjustmentContainer;
protected override bool UserScrollSpeedAdjustment => false; protected override bool UserScrollSpeedAdjustment => false;
private SkinnableDrawable scroller; private SkinnableDrawable scroller;
@ -68,22 +69,7 @@ namespace osu.Game.Rulesets.Taiko.UI
TimeRange.Value = ComputeTimeRange(); TimeRange.Value = ComputeTimeRange();
} }
protected virtual double ComputeTimeRange() protected virtual double ComputeTimeRange() => PlayfieldAdjustmentContainer.ComputeTimeRange();
{
// Taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened.
const float scroll_rate = 10;
// Since the time range will depend on a positional value, it is referenced to the x480 pixel space.
// Width is used because it defines how many notes fit on the playfield.
// We clamp the ratio to the maximum aspect ratio to keep scroll speed consistent on widths lower than the default.
float ratio = Math.Max(DrawSize.X / 768f, TaikoPlayfieldAdjustmentContainer.MAXIMUM_ASPECT);
// Stable internally increased the slider velocity of objects by a factor of `VELOCITY_MULTIPLIER`.
// To simulate this, we shrink the time range by that factor here.
// This, when combined with the rest of the scrolling ruleset machinery (see `MultiplierControlPoint` et al.),
// has the effect of increasing each multiplier control point's multiplier by `VELOCITY_MULTIPLIER`, ensuring parity with stable.
return (Playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate / TaikoBeatmapConverter.VELOCITY_MULTIPLIER;
}
protected override void UpdateAfterChildren() protected override void UpdateAfterChildren()
{ {

View File

@ -4,6 +4,7 @@
using System; using System;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osuTK; using osuTK;
@ -14,6 +15,8 @@ namespace osu.Game.Rulesets.Taiko.UI
public const float MAXIMUM_ASPECT = 16f / 9f; public const float MAXIMUM_ASPECT = 16f / 9f;
public const float MINIMUM_ASPECT = 5f / 4f; public const float MINIMUM_ASPECT = 5f / 4f;
private const float stable_gamefield_height = 480f;
public readonly IBindable<bool> LockPlayfieldAspectRange = new BindableBool(true); public readonly IBindable<bool> LockPlayfieldAspectRange = new BindableBool(true);
public TaikoPlayfieldAdjustmentContainer() public TaikoPlayfieldAdjustmentContainer()
@ -21,6 +24,9 @@ namespace osu.Game.Rulesets.Taiko.UI
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
RelativePositionAxes = Axes.Y; RelativePositionAxes = Axes.Y;
Height = TaikoPlayfield.BASE_HEIGHT; Height = TaikoPlayfield.BASE_HEIGHT;
// Matches stable, see https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Play/Rulesets/Taiko/RulesetTaiko.cs#L514
Y = 135f / stable_gamefield_height;
} }
protected override void Update() protected override void Update()
@ -28,8 +34,6 @@ namespace osu.Game.Rulesets.Taiko.UI
base.Update(); base.Update();
const float base_relative_height = TaikoPlayfield.BASE_HEIGHT / 768; const float base_relative_height = TaikoPlayfield.BASE_HEIGHT / 768;
// Matches stable, see https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Play/Rulesets/Taiko/RulesetTaiko.cs#L514
const float base_position = 135f / 480f;
float relativeHeight = base_relative_height; float relativeHeight = base_relative_height;
@ -51,10 +55,38 @@ namespace osu.Game.Rulesets.Taiko.UI
// Limit the maximum relative height of the playfield to one-third of available area to avoid it masking out on extreme resolutions. // Limit the maximum relative height of the playfield to one-third of available area to avoid it masking out on extreme resolutions.
relativeHeight = Math.Min(relativeHeight, 1f / 3f); relativeHeight = Math.Min(relativeHeight, 1f / 3f);
Y = base_position;
Scale = new Vector2(Math.Max((Parent!.ChildSize.Y / 768f) * (relativeHeight / base_relative_height), 1f)); Scale = new Vector2(Math.Max((Parent!.ChildSize.Y / 768f) * (relativeHeight / base_relative_height), 1f));
Width = 1 / Scale.X; Width = 1 / Scale.X;
} }
public double ComputeTimeRange()
{
float currentAspect = Parent!.ChildSize.X / Parent!.ChildSize.Y;
if (LockPlayfieldAspectRange.Value)
currentAspect = Math.Clamp(currentAspect, MINIMUM_ASPECT, MAXIMUM_ASPECT);
// in a game resolution of 1024x768, stable's scrolling system consists of objects being placed 600px (widthScaled - 40) away from their hit location.
// however, the point at which the object renders at the end of the screen is exactly x=640, but stable makes the object start moving from beyond the screen instead of the boundary point.
// therefore, in lazer we have to adjust the "in length" so that it's in a 640px->160px fashion before passing it down as a "time range".
// see stable's "in length": https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L168
const float stable_hit_location = 160f;
float widthScaled = currentAspect * stable_gamefield_height;
float inLength = widthScaled - stable_hit_location;
// also in a game resolution of 1024x768, stable makes hit objects scroll from 760px->160px at a duration of 6000ms, divided by slider velocity (i.e. at a rate of 0.1px/ms)
// compare: https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L218
// note: the variable "sv", in the linked reference, is equivalent to MultiplierControlPoint.Multiplier * 100, but since time range is agnostic of velocity, we replace "sv" with 100 below.
float inMsLength = inLength / 100 * 1000;
// stable multiplies the slider velocity by 1.4x for certain reasons, divide the time range by that factor to achieve similar result.
// for references on how the factor is applied to the time range, see:
// 1. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L79 (DifficultySliderMultiplier multiplied by 1.4x)
// 2. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManager.cs#L468-L470 (DifficultySliderMultiplier used to calculate SliderScoringPointDistance)
// 3. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManager.cs#L248-L250 (SliderScoringPointDistance used to calculate slider velocity, i.e. the "sv" variable from above)
inMsLength /= TaikoBeatmapConverter.VELOCITY_MULTIPLIER;
return inMsLength;
}
} }
} }

View File

@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat
return false; return false;
}; };
}); });
AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected);
} }
[Test] [Test]

View File

@ -169,9 +169,9 @@ namespace osu.Game.Tests.NonVisual.Skinning
public IRenderer Renderer => new DummyRenderer(); public IRenderer Renderer => new DummyRenderer();
public AudioManager AudioManager => null; public AudioManager AudioManager => null;
public IResourceStore<byte[]> Files => null; public IResourceStore<byte[]> Files => null!;
public IResourceStore<byte[]> Resources => null; public IResourceStore<byte[]> Resources => null!;
public RealmAccess RealmAccess => null; public RealmAccess RealmAccess => null!;
public IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => textureStore; public IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => textureStore;
} }
} }

View File

@ -47,8 +47,8 @@ namespace osu.Game.Tests.Visual.Gameplay
seekTo(referenceBeatmap.HitObjects[^1].GetEndTime()); seekTo(referenceBeatmap.HitObjects[^1].GetEndTime());
AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true); AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true);
AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); AddAssert("score has combo", () => getResultsScreen().Score!.Combo > 100);
AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0); AddAssert("score has no misses", () => getResultsScreen().Score!.Statistics[HitResult.Miss] == 0);
AddUntilStep("avatar displayed", () => getAvatar() != null); AddUntilStep("avatar displayed", () => getAvatar() != null);
AddAssert("avatar not clickable", () => getAvatar().ChildrenOfType<OsuClickableContainer>().First().Action == null); AddAssert("avatar not clickable", () => getAvatar().ChildrenOfType<OsuClickableContainer>().First().Action == null);

View File

@ -138,8 +138,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen);
// Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained. // Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained.
AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.Not.SameAs(playerMods.First())); AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.Not.SameAs(playerMods.First()));
AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First())); AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.EqualTo(playerMods.First()));
AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null)); AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null));
AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First())); AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First()));
@ -184,7 +184,11 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest(); CreateTest();
AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning);
AddStep("log back in", () => API.Login("username", "password")); AddStep("log back in", () =>
{
API.Login("username", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime()));

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq; using System.Linq;
using System.Net;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -9,7 +10,9 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Login;
using osu.Game.Users.Drawables; using osu.Game.Users.Drawables;
using osuTK.Input; using osuTK.Input;
@ -18,6 +21,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture] [TestFixture]
public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene
{ {
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private LoginOverlay loginOverlay = null!; private LoginOverlay loginOverlay = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -40,9 +45,69 @@ namespace osu.Game.Tests.Visual.Menus
public void TestLoginSuccess() public void TestLoginSuccess()
{ {
AddStep("logout", () => API.Logout()); AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password"); AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick()); AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "88800088")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
assertAPIState(APIState.Online);
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
}
private void assertAPIState(APIState expected) =>
AddUntilStep($"login state is {expected}", () => API.State.Value, () => Is.EqualTo(expected));
[Test]
public void TestVerificationFailure()
{
bool verificationHandled = false;
AddStep("reset flag", () => verificationHandled = false);
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "88800088")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
verificationHandled = true;
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "abcdefgh");
AddUntilStep("wait for verification handled", () => verificationHandled);
assertAPIState(APIState.RequiresSecondFactorAuth);
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
} }
[Test] [Test]
@ -78,6 +143,12 @@ namespace osu.Game.Tests.Visual.Menus
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password"); AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick()); AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
assertAPIState(APIState.Online);
AddStep("click on flag", () => AddStep("click on flag", () =>
{ {
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<UpdateableFlag>().First()); InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<UpdateableFlag>().First());

View File

@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture] [TestFixture]
public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene
{ {
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
public TestSceneToolbarUserButton() public TestSceneToolbarUserButton()
{ {
Container mainContainer; Container mainContainer;
@ -69,18 +71,20 @@ namespace osu.Game.Tests.Visual.Menus
[Test] [Test]
public void TestLoginLogout() public void TestLoginLogout()
{ {
AddStep("Log out", () => ((DummyAPIAccess)API).Logout()); AddStep("Log out", () => dummyAPI.Logout());
AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh"));
} }
[Test] [Test]
public void TestStates() public void TestStates()
{ {
AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh"));
foreach (var state in Enum.GetValues<APIState>()) foreach (var state in Enum.GetValues<APIState>())
{ {
AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state)); AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
} }
} }
} }

View File

@ -698,7 +698,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
var scoreInfo = ((ResultsScreen)multiplayerComponents.CurrentScreen).Score; var scoreInfo = ((ResultsScreen)multiplayerComponents.CurrentScreen).Score;
return !scoreInfo.Passed && scoreInfo.Rank == ScoreRank.F; return scoreInfo?.Passed == false && scoreInfo.Rank == ScoreRank.F;
}); });
} }

View File

@ -3,13 +3,18 @@
#nullable disable #nullable disable
using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -19,14 +24,37 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
private MultiplayerPlayer player; private MultiplayerPlayer player;
[SetUpSteps] [Test]
public override void SetUpSteps() public void TestGameplay()
{ {
base.SetUpSteps(); setup();
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
}
[Test]
public void TestFail()
{
setup(() => new[] { new OsuModAutopilot() });
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
AddStep("set health zero", () => player.ChildrenOfType<HealthProcessor>().Single().Health.Value = 0);
AddUntilStep("wait for fail", () => player.ChildrenOfType<HealthProcessor>().Single().HasFailed);
AddAssert("fail animation not shown", () => !player.GameplayState.HasFailed);
// ensure that even after reaching a failed state, score processor keeps accounting for new hit results.
// the testing method used here (autopilot + hold key) is sort-of dodgy, but works enough.
AddAssert("score is zero", () => player.GameplayState.ScoreProcessor.TotalScore.Value == 0);
AddStep("hold key", () => player.ChildrenOfType<OsuInputManager.RulesetKeyBindingContainer>().First().TriggerPressed(OsuAction.LeftButton));
AddUntilStep("score changed", () => player.GameplayState.ScoreProcessor.TotalScore.Value > 0);
}
private void setup(Func<IReadOnlyList<Mod>> mods = null)
{
AddStep("set beatmap", () => AddStep("set beatmap", () =>
{ {
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
SelectedMods.Value = mods?.Invoke() ?? Array.Empty<Mod>();
}); });
AddStep("Start track playing", () => AddStep("Start track playing", () =>
@ -52,11 +80,5 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("gameplay clock is not paused", () => !player.ChildrenOfType<GameplayClockContainer>().Single().IsPaused.Value); AddUntilStep("gameplay clock is not paused", () => !player.ChildrenOfType<GameplayClockContainer>().Single().IsPaused.Value);
AddAssert("gameplay clock is running", () => player.ChildrenOfType<GameplayClockContainer>().Single().IsRunning); AddAssert("gameplay clock is running", () => player.ChildrenOfType<GameplayClockContainer>().Single().IsRunning);
} }
[Test]
public void TestGameplay()
{
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
}
} }
} }

View File

@ -193,7 +193,7 @@ namespace osu.Game.Tests.Visual.Navigation
case ScorePresentType.Results: case ScorePresentType.Results:
AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen); AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen);
AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen); AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen);
AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.Equals(getImport())); AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score!.Equals(getImport()));
AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Ruleset)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Ruleset));
break; break;

View File

@ -10,6 +10,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.AccountCreation; using osu.Game.Overlays.AccountCreation;
@ -59,7 +60,11 @@ namespace osu.Game.Tests.Visual.Online
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick()); AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true); AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
AddStep("log back in", () => API.Login("dummy", "password")); AddStep("log back in", () =>
{
API.Login("dummy", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden); AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden);
} }
} }

View File

@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online
Schedule(() => Schedule(() =>
{ {
API.Login("test", "test"); API.Login("test", "test");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
Child = commentsContainer = new CommentsContainer(); Child = commentsContainer = new CommentsContainer();
}); });
} }

View File

@ -6,6 +6,7 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.BeatmapSet.Buttons; using osu.Game.Overlays.BeatmapSet.Buttons;
using osuTK; using osuTK;
@ -34,14 +35,22 @@ namespace osu.Game.Tests.Visual.Online
AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 });
AddStep("log out", () => API.Logout()); AddStep("log out", () => API.Logout());
checkEnabled(false); checkEnabled(false);
AddStep("log in", () => API.Login("test", "test")); AddStep("log in", () =>
{
API.Login("test", "test");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
checkEnabled(true); checkEnabled(true);
} }
[Test] [Test]
public void TestBeatmapChange() public void TestBeatmapChange()
{ {
AddStep("log in", () => API.Login("test", "test")); AddStep("log in", () =>
{
API.Login("test", "test");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 });
checkEnabled(true); checkEnabled(true);
AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet()); AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet());

View File

@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online
else else
{ {
int userId = int.Parse(getUserRequest.Lookup); int userId = int.Parse(getUserRequest.Lookup);
string rulesetName = getUserRequest.Ruleset.ShortName; string rulesetName = getUserRequest.Ruleset!.ShortName;
var response = new APIUser var response = new APIUser
{ {
Id = userId, Id = userId,
@ -177,7 +177,11 @@ namespace osu.Game.Tests.Visual.Online
AddWaitStep("wait a bit", 5); AddWaitStep("wait a bit", 5);
AddAssert("update not received", () => update == null); AddAssert("update not received", () => update == null);
AddStep("log in user", () => dummyAPI.Login("user", "password")); AddStep("log in user", () =>
{
dummyAPI.Login("user", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
} }
[Test] [Test]

View File

@ -52,7 +52,11 @@ namespace osu.Game.Tests.Visual.Online
AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 }));
AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden); AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden);
AddStep("log out", () => dummyAPI.Logout()); AddStep("log out", () => dummyAPI.Logout());
AddStep("log back in", () => dummyAPI.Login("username", "password")); AddStep("log back in", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
} }
[Test] [Test]
@ -98,7 +102,11 @@ namespace osu.Game.Tests.Visual.Online
}); });
AddStep("logout", () => dummyAPI.Logout()); AddStep("logout", () => dummyAPI.Logout());
AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 }));
AddStep("login", () => dummyAPI.Login("username", "password")); AddStep("login", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
AddWaitStep("wait some", 3); AddWaitStep("wait some", 3);
AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER)); AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER));
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Online.API;
namespace osu.Game.Tests.Visual.Online namespace osu.Game.Tests.Visual.Online
{ {
@ -72,7 +73,11 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible); AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible);
} }
private void logIn() => API.Login("localUser", "password"); private void logIn()
{
API.Login("localUser", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
}
private Comment getUserComment() => new Comment private Comment getUserComment() => new Comment
{ {

View File

@ -420,7 +420,7 @@ namespace osu.Game.Tests.Visual.Playlists
public new LoadingSpinner RightSpinner => base.RightSpinner; public new LoadingSpinner RightSpinner => base.RightSpinner;
public new ScorePanelList ScorePanelList => base.ScorePanelList; public new ScorePanelList ScorePanelList => base.ScorePanelList;
public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) public TestResultsScreen([CanBeNull] ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true)
: base(score, roomId, playlistItem, allowRetry) : base(score, roomId, playlistItem, allowRetry)
{ {
} }

View File

@ -466,7 +466,7 @@ namespace osu.Game.Tests.Visual.Ranking
public UnrankedSoloResultsScreen(ScoreInfo score) public UnrankedSoloResultsScreen(ScoreInfo score)
: base(score, true) : base(score, true)
{ {
Score.BeatmapInfo!.OnlineID = 0; Score!.BeatmapInfo!.OnlineID = 0;
Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending; Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending;
} }

View File

@ -125,7 +125,11 @@ namespace osu.Game.Tests.Visual.UserInterface
assertLoggedOutState(); assertLoggedOutState();
// moving from logged out -> logged in // moving from logged out -> logged in
AddStep("log back in", () => dummyAPI.Login("username", "password")); AddStep("log back in", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
assertLoggedInState(); assertLoggedInState();
} }

View File

@ -21,7 +21,6 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary> /// </summary>
public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1) public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1)
{ {
Precision = 0.01,
MinValue = 0.01, MinValue = 0.01,
MaxValue = 10 MaxValue = 10
}; };

View File

@ -1,17 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Utils;
using osuTK;
using System; using System;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Textures;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Allocation;
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Graphics.Rendering; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
using osuTK;
namespace osu.Game.Graphics.Backgrounds namespace osu.Game.Graphics.Backgrounds
{ {
@ -27,6 +27,8 @@ namespace osu.Game.Graphics.Backgrounds
public float Thickness { get; set; } = 0.02f; // No need for invalidation since it's happening in Update() public float Thickness { get; set; } = 0.02f; // No need for invalidation since it's happening in Update()
public float ScaleAdjust { get; set; } = 1;
/// <summary> /// <summary>
/// Whether we should create new triangles as others expire. /// Whether we should create new triangles as others expire.
/// </summary> /// </summary>
@ -106,7 +108,7 @@ namespace osu.Game.Graphics.Backgrounds
parts[i] = newParticle; parts[i] = newParticle;
float bottomPos = parts[i].Position.Y + triangle_size * equilateral_triangle_ratio / DrawHeight; float bottomPos = parts[i].Position.Y + triangle_size * ScaleAdjust * equilateral_triangle_ratio / DrawHeight;
if (bottomPos < 0) if (bottomPos < 0)
parts.RemoveAt(i); parts.RemoveAt(i);
} }
@ -149,7 +151,7 @@ namespace osu.Game.Graphics.Backgrounds
if (randomY) if (randomY)
{ {
// since triangles are drawn from the top - allow them to be positioned a bit above the screen // since triangles are drawn from the top - allow them to be positioned a bit above the screen
float maxOffset = triangle_size * equilateral_triangle_ratio / DrawHeight; float maxOffset = triangle_size * ScaleAdjust * equilateral_triangle_ratio / DrawHeight;
y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f); y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f);
} }
@ -188,7 +190,7 @@ namespace osu.Game.Graphics.Backgrounds
private readonly List<TriangleParticle> parts = new List<TriangleParticle>(); private readonly List<TriangleParticle> parts = new List<TriangleParticle>();
private readonly Vector2 triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size; private Vector2 triangleSize;
private Vector2 size; private Vector2 size;
private float thickness; private float thickness;
@ -209,6 +211,7 @@ namespace osu.Game.Graphics.Backgrounds
size = Source.DrawSize; size = Source.DrawSize;
thickness = Source.Thickness; thickness = Source.Thickness;
clampAxes = Source.ClampAxes; clampAxes = Source.ClampAxes;
triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size * Source.ScaleAdjust;
Quad triangleQuad = new Quad( Quad triangleQuad = new Quad(
Vector2Extensions.Transform(Vector2.Zero, DrawInfo.Matrix), Vector2Extensions.Transform(Vector2.Zero, DrawInfo.Matrix),

View File

@ -77,7 +77,7 @@ namespace osu.Game.Graphics
{ {
case HitResult.IgnoreMiss: case HitResult.IgnoreMiss:
case HitResult.SmallTickMiss: case HitResult.SmallTickMiss:
return Orange1; return Color4.Gray;
case HitResult.Miss: case HitResult.Miss:
case HitResult.LargeTickMiss: case HitResult.LargeTickMiss:

View File

@ -21,7 +21,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications; using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket; using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users; using osu.Game.Users;
@ -48,6 +48,8 @@ namespace osu.Game.Online.API
public string ProvidedUsername { get; private set; } public string ProvidedUsername { get; private set; }
public string SecondFactorCode { get; private set; }
private string password; private string password;
public IBindable<APIUser> LocalUser => localUser; public IBindable<APIUser> LocalUser => localUser;
@ -55,6 +57,8 @@ namespace osu.Game.Online.API
public IBindable<UserActivity> Activity => activity; public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics; public IBindable<UserStatistics> Statistics => statistics;
public INotificationsClient NotificationsClient { get; }
public Language Language => game.CurrentLanguage.Value; public Language Language => game.CurrentLanguage.Value;
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser()); private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
@ -82,6 +86,7 @@ namespace osu.Game.Online.API
APIEndpointUrl = endpointConfiguration.APIEndpointUrl; APIEndpointUrl = endpointConfiguration.APIEndpointUrl;
WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl;
NotificationsClient = setUpNotificationsClient();
authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl);
log = Logger.GetLogger(LoggingTarget.Network); log = Logger.GetLogger(LoggingTarget.Network);
@ -114,6 +119,30 @@ namespace osu.Game.Online.API
thread.Start(); thread.Start();
} }
private WebSocketNotificationsClientConnector setUpNotificationsClient()
{
var connector = new WebSocketNotificationsClientConnector(this);
connector.MessageReceived += msg =>
{
switch (msg.Event)
{
case @"verified":
if (state.Value == APIState.RequiresSecondFactorAuth)
state.Value = APIState.Online;
break;
case @"logout":
if (state.Value == APIState.Online)
Logout();
break;
}
};
return connector;
}
private void onTokenChanged(ValueChangedEvent<OAuthToken> e) => config.SetValue(OsuSetting.Token, config.Get<bool>(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); private void onTokenChanged(ValueChangedEvent<OAuthToken> e) => config.SetValue(OsuSetting.Token, config.Get<bool>(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty);
internal new void Schedule(Action action) => base.Schedule(action); internal new void Schedule(Action action) => base.Schedule(action);
@ -197,6 +226,7 @@ namespace osu.Game.Online.API
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either /// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either
/// - <see cref="APIState.RequiresSecondFactorAuth"/> (pending 2fa)
/// - <see cref="APIState.Online"/> (successful connection) /// - <see cref="APIState.Online"/> (successful connection)
/// - <see cref="APIState.Failing"/> (failed connection but retrying) /// - <see cref="APIState.Failing"/> (failed connection but retrying)
/// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction) /// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction)
@ -204,8 +234,6 @@ namespace osu.Game.Online.API
/// <returns>Whether the connection attempt was successful.</returns> /// <returns>Whether the connection attempt was successful.</returns>
private void attemptConnect() private void attemptConnect()
{ {
state.Value = APIState.Connecting;
if (localUser.IsDefault) if (localUser.IsDefault)
{ {
// Show a placeholder user if saved credentials are available. // Show a placeholder user if saved credentials are available.
@ -223,6 +251,7 @@ namespace osu.Game.Online.API
if (!authentication.HasValidAccessToken) if (!authentication.HasValidAccessToken)
{ {
state.Value = APIState.Connecting;
LastLoginError = null; LastLoginError = null;
try try
@ -240,40 +269,79 @@ namespace osu.Game.Online.API
} }
} }
var userReq = new GetUserRequest(); switch (state.Value)
userReq.Failure += ex =>
{ {
if (ex is APIException) case APIState.RequiresSecondFactorAuth:
{ {
LastLoginError = ex; if (string.IsNullOrEmpty(SecondFactorCode))
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); return;
Logout();
state.Value = APIState.Connecting;
LastLoginError = null;
var verificationRequest = new VerifySessionRequest(SecondFactorCode);
verificationRequest.Success += () => state.Value = APIState.Online;
verificationRequest.Failure += ex =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = ex;
SecondFactorCode = null;
};
if (!handleRequest(verificationRequest))
{
state.Value = APIState.Failing;
return;
}
if (state.Value != APIState.Online)
return;
break;
} }
else if (ex is WebException webException && webException.Message == @"Unauthorized")
default:
{ {
log.Add(@"Login no longer valid"); var userReq = new GetMeRequest();
Logout();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += me =>
{
me.Status.Value = configStatus.Value ?? UserStatus.Online;
setLocalUser(me);
state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
}
break;
} }
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += user =>
{
user.Status.Value = configStatus.Value ?? UserStatus.Online;
setLocalUser(user);
// we're connected!
state.Value = APIState.Online;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
} }
var friendsReq = new GetFriendsRequest(); var friendsReq = new GetFriendsRequest();
@ -321,11 +389,17 @@ namespace osu.Game.Online.API
this.password = password; this.password = password;
} }
public void AuthenticateSecondFactor(string code)
{
Debug.Assert(State.Value == APIState.RequiresSecondFactorAuth);
SecondFactorCode = code;
}
public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) =>
new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack);
public NotificationsClientConnector GetNotificationsConnector() => public IChatClient GetChatClient() => new WebSocketChatClient(this);
new WebSocketNotificationsClientConnector(this);
public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password)
{ {
@ -507,6 +581,7 @@ namespace osu.Game.Online.API
public void Logout() public void Logout()
{ {
password = null; password = null;
SecondFactorCode = null;
authentication.Clear(); authentication.Clear();
// Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present
@ -566,6 +641,11 @@ namespace osu.Game.Online.API
/// </summary> /// </summary>
Failing, Failing,
/// <summary>
/// Waiting on second factor authentication.
/// </summary>
RequiresSecondFactorAuth,
/// <summary> /// <summary>
/// We are in the process of (re-)connecting. /// We are in the process of (re-)connecting.
/// </summary> /// </summary>

View File

@ -7,8 +7,10 @@ using System.Threading.Tasks;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications; using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Tests; using osu.Game.Tests;
using osu.Game.Users; using osu.Game.Users;
@ -30,6 +32,9 @@ namespace osu.Game.Online.API
public Bindable<UserStatistics?> Statistics { get; } = new Bindable<UserStatistics?>(); public Bindable<UserStatistics?> Statistics { get; } = new Bindable<UserStatistics?>();
public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient();
INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient;
public Language Language => Language.en; public Language Language => Language.en;
public string AccessToken => "token"; public string AccessToken => "token";
@ -57,6 +62,7 @@ namespace osu.Game.Online.API
private bool shouldFailNextLogin; private bool shouldFailNextLogin;
private bool stayConnectingNextLogin; private bool stayConnectingNextLogin;
private bool requiredSecondFactorAuth = true;
/// <summary> /// <summary>
/// The current connectivity state of the API. /// The current connectivity state of the API.
@ -117,13 +123,46 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID, Id = DUMMY_USER_ID,
}; };
if (requiredSecondFactorAuth)
{
state.Value = APIState.RequiresSecondFactorAuth;
}
else
{
onSuccessfulLogin();
requiredSecondFactorAuth = true;
}
}
public void AuthenticateSecondFactor(string code)
{
var request = new VerifySessionRequest(code);
request.Failure += e =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = e;
};
state.Value = APIState.Connecting;
LastLoginError = null;
// if no handler installed / handler can't handle verification, just assume that the server would verify for simplicity.
if (HandleRequest?.Invoke(request) != true)
onSuccessfulLogin();
// if a handler did handle this, make sure the verification actually passed.
if (request.CompletionState == APIRequestCompletionState.Completed)
onSuccessfulLogin();
}
private void onSuccessfulLogin()
{
state.Value = APIState.Online;
Statistics.Value = new UserStatistics Statistics.Value = new UserStatistics
{ {
GlobalRank = 1, GlobalRank = 1,
CountryRank = 1 CountryRank = 1
}; };
state.Value = APIState.Online;
} }
public void Logout() public void Logout()
@ -144,7 +183,7 @@ namespace osu.Game.Online.API
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this); public IChatClient GetChatClient() => new TestChatClientConnector(this);
public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password)
{ {
@ -159,6 +198,11 @@ namespace osu.Game.Online.API
IBindable<UserActivity> IAPIProvider.Activity => Activity; IBindable<UserActivity> IAPIProvider.Activity => Activity;
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics; IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;
/// <summary>
/// Skip 2FA requirement for next login.
/// </summary>
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
/// <summary> /// <summary>
/// During the next simulated login, the process will fail immediately. /// During the next simulated login, the process will fail immediately.
/// </summary> /// </summary>

View File

@ -6,7 +6,8 @@ using System.Threading.Tasks;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications; using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Online.API namespace osu.Game.Online.API
@ -111,6 +112,12 @@ namespace osu.Game.Online.API
/// <param name="password">The user's password.</param> /// <param name="password">The user's password.</param>
void Login(string username, string password); void Login(string username, string password);
/// <summary>
/// Provide a second-factor authentication code for authentication.
/// </summary>
/// <param name="code">The 2FA code.</param>
void AuthenticateSecondFactor(string code);
/// <summary> /// <summary>
/// Log out the current user. /// Log out the current user.
/// </summary> /// </summary>
@ -130,9 +137,14 @@ namespace osu.Game.Online.API
IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true);
/// <summary> /// <summary>
/// Constructs a new <see cref="NotificationsClientConnector"/>. /// Accesses the <see cref="INotificationsClient"/> used to receive asynchronous notifications from web.
/// </summary> /// </summary>
NotificationsClientConnector GetNotificationsConnector(); INotificationsClient NotificationsClient { get; }
/// <summary>
/// Creates a <see cref="IChatClient"/> instance to use in order to chat.
/// </summary>
IChatClient GetChatClient();
/// <summary> /// <summary>
/// Create a new user account. This is a blocking operation. /// Create a new user account. This is a blocking operation.

View File

@ -128,19 +128,12 @@ namespace osu.Game.Online.API
// if we already have a valid access token, let's use it. // if we already have a valid access token, let's use it.
if (accessTokenValid) return true; if (accessTokenValid) return true;
// we want to ensure only a single authentication update is happening at once. // if not, let's try using our refresh token to request a new access token.
lock (access_token_retrieval_lock) if (!string.IsNullOrEmpty(Token.Value?.RefreshToken))
{ // ReSharper disable once PossibleNullReferenceException
// re-check if valid, in case another request completed and revalidated our access. AuthenticateWithRefresh(Token.Value.RefreshToken);
if (accessTokenValid) return true;
// if not, let's try using our refresh token to request a new access token. return accessTokenValid;
if (!string.IsNullOrEmpty(Token.Value?.RefreshToken))
// ReSharper disable once PossibleNullReferenceException
AuthenticateWithRefresh(Token.Value.RefreshToken);
return accessTokenValid;
}
} }
private bool accessTokenValid => Token.Value?.IsValid ?? false; private bool accessTokenValid => Token.Value?.IsValid ?? false;
@ -149,14 +142,18 @@ namespace osu.Game.Online.API
internal string RequestAccessToken() internal string RequestAccessToken()
{ {
if (!ensureAccessToken()) return null; lock (access_token_retrieval_lock)
{
if (!ensureAccessToken()) return null;
return Token.Value.AccessToken; return Token.Value.AccessToken;
}
} }
internal void Clear() internal void Clear()
{ {
Token.Value = null; lock (access_token_retrieval_lock)
Token.Value = null;
} }
private class AccessTokenRequestRefresh : AccessTokenRequest private class AccessTokenRequestRefresh : AccessTokenRequest

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
namespace osu.Game.Online.API.Requests
{
public class GetMeRequest : APIRequest<APIMe>
{
public readonly IRulesetInfo? Ruleset;
/// <summary>
/// Gets the currently logged-in user.
/// </summary>
/// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetMeRequest(IRulesetInfo? ruleset = null)
{
Ruleset = ruleset;
}
protected override string Target => $@"me/{Ruleset?.ShortName}";
}
}

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests namespace osu.Game.Online.API.Requests
@ -9,7 +8,7 @@ namespace osu.Game.Online.API.Requests
public class GetSystemTitleRequest : OsuJsonWebRequest<APISystemTitle> public class GetSystemTitleRequest : OsuJsonWebRequest<APISystemTitle>
{ {
public GetSystemTitleRequest() public GetSystemTitleRequest()
: base($@"https://assets.ppy.sh/lazer-status.json?{DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 1800}") : base(@"https://assets.ppy.sh/lazer-status.json")
{ {
} }
} }

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -11,24 +9,17 @@ namespace osu.Game.Online.API.Requests
public class GetUserRequest : APIRequest<APIUser> public class GetUserRequest : APIRequest<APIUser>
{ {
public readonly string Lookup; public readonly string Lookup;
public readonly IRulesetInfo Ruleset; public readonly IRulesetInfo? Ruleset;
private readonly LookupType lookupType; private readonly LookupType lookupType;
/// <summary>
/// Gets the currently logged-in user.
/// </summary>
public GetUserRequest()
{
}
/// <summary> /// <summary>
/// Gets a user from their ID. /// Gets a user from their ID.
/// </summary> /// </summary>
/// <param name="userId">The user to get.</param> /// <param name="userId">The user to get.</param>
/// <param name="ruleset">The ruleset to get the user's info for.</param> /// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null) public GetUserRequest(long? userId = null, IRulesetInfo? ruleset = null)
{ {
Lookup = userId.ToString(); Lookup = userId.ToString()!;
lookupType = LookupType.Id; lookupType = LookupType.Id;
Ruleset = ruleset; Ruleset = ruleset;
} }
@ -38,14 +29,14 @@ namespace osu.Game.Online.API.Requests
/// </summary> /// </summary>
/// <param name="username">The user to get.</param> /// <param name="username">The user to get.</param>
/// <param name="ruleset">The ruleset to get the user's info for.</param> /// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetUserRequest(string username = null, IRulesetInfo ruleset = null) public GetUserRequest(string username, IRulesetInfo? ruleset = null)
{ {
Lookup = username; Lookup = username;
lookupType = LookupType.Username; lookupType = LookupType.Username;
Ruleset = ruleset; Ruleset = ruleset;
} }
protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}" : $@"me/{Ruleset?.ShortName}"; protected override string Target => $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}";
private enum LookupType private enum LookupType
{ {

View File

@ -0,0 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class ReissueVerificationCodeRequest : APIRequest
{
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
return req;
}
protected override string Target => @"session/verify/reissue";
}
}

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIMe : APIUser
{
[JsonProperty("session_verified")]
public bool SessionVerified { get; set; }
}
}

View File

@ -0,0 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class VerifySessionRequest : APIRequest
{
public readonly string VerificationKey;
public VerifySessionRequest(string verificationKey)
{
VerificationKey = verificationKey;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter(@"verification_key", VerificationKey);
return req;
}
protected override string Target => @"session/verify";
}
}

View File

@ -16,7 +16,6 @@ using osu.Game.Database;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Overlays.Chat.Listing; using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Online.Chat namespace osu.Game.Online.Chat
@ -64,13 +63,8 @@ namespace osu.Game.Online.Chat
/// </summary> /// </summary>
public IBindableList<Channel> AvailableChannels => availableChannels; public IBindableList<Channel> AvailableChannels => availableChannels;
/// <summary>
/// Whether the client responsible for channel notifications is connected.
/// </summary>
public bool NotificationsConnected => connector.IsConnected.Value;
private readonly IAPIProvider api; private readonly IAPIProvider api;
private readonly NotificationsClientConnector connector; private readonly IChatClient chatClient;
[Resolved] [Resolved]
private UserLookupCache users { get; set; } private UserLookupCache users { get; set; }
@ -85,7 +79,7 @@ namespace osu.Game.Online.Chat
{ {
this.api = api; this.api = api;
connector = api.GetNotificationsConnector(); chatClient = api.GetChatClient();
CurrentChannel.ValueChanged += currentChannelChanged; CurrentChannel.ValueChanged += currentChannelChanged;
} }
@ -93,15 +87,11 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
connector.ChannelJoined += ch => Schedule(() => joinChannel(ch)); chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch));
chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false)); chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs));
chatClient.PresenceReceived += () => Schedule(initializeChannels);
connector.NewMessages += msgs => Schedule(() => addMessages(msgs)); chatClient.RequestPresence();
connector.PresenceReceived += () => Schedule(initializeChannels);
connector.Start();
apiState.BindTo(api.State); apiState.BindTo(api.State);
apiState.BindValueChanged(_ => SendAck(), true); apiState.BindValueChanged(_ => SendAck(), true);
@ -655,7 +645,7 @@ namespace osu.Game.Online.Chat
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
connector?.Dispose(); chatClient?.Dispose();
} }
} }

View File

@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
namespace osu.Game.Online.Chat
{
/// <summary>
/// Interface for consuming online chat.
/// </summary>
public interface IChatClient : IDisposable
{
/// <summary>
/// Fired when a <see cref="Channel"/> has been joined.
/// </summary>
event Action<Channel>? ChannelJoined;
/// <summary>
/// Fired when a <see cref="Channel"/> has been parted.
/// </summary>
event Action<Channel>? ChannelParted;
/// <summary>
/// Fired when new <see cref="Message"/>s have arrived from the server.
/// </summary>
event Action<List<Message>>? NewMessages;
/// <summary>
/// Requests presence information from the server.
/// </summary>
void RequestPresence();
/// <summary>
/// Fired when the initial user presence information has been received.
/// </summary>
event Action? PresenceReceived;
}
}

View File

@ -0,0 +1,173 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Notifications.WebSocket;
namespace osu.Game.Online.Chat
{
public class WebSocketChatClient : IChatClient
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
private readonly IAPIProvider api;
private readonly INotificationsClient client;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
private CancellationTokenSource? chatStartCancellationSource;
public WebSocketChatClient(IAPIProvider api)
{
this.api = api;
client = api.NotificationsClient;
client.IsConnected.BindValueChanged(onConnectedChanged, true);
}
private void onConnectedChanged(ValueChangedEvent<bool> connected)
{
if (connected.NewValue)
{
client.MessageReceived += onMessageReceived;
attemptToStartChat();
RequestPresence();
}
else
chatStartCancellationSource?.Cancel();
}
private void attemptToStartChat()
{
chatStartCancellationSource?.Cancel();
chatStartCancellationSource = new CancellationTokenSource();
Task.Factory.StartNew(async () =>
{
while (!chatStartCancellationSource.IsCancellationRequested)
{
try
{
await client.SendAsync(new StartChatRequest()).ConfigureAwait(false);
Logger.Log(@"Now listening to websocket chat messages.", LoggingTarget.Network);
chatStartCancellationSource.Cancel();
}
catch (Exception ex)
{
Logger.Log($@"Could not start listening to websocket chat messages: {ex}", LoggingTarget.Network);
await Task.Delay(5000).ConfigureAwait(false);
}
}
}, chatStartCancellationSource.Token);
}
public void RequestPresence()
{
var fetchReq = new GetUpdatesRequest(0);
fetchReq.Success += updates =>
{
if (updates?.Presence != null)
{
foreach (var channel in updates.Presence)
joinChannel(channel);
handleMessages(updates.Messages);
}
PresenceReceived?.Invoke();
};
api.Queue(fetchReq);
}
private void onMessageReceived(SocketMessage message)
{
switch (message.Event)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
joinChannel(joinedChannel);
break;
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
partChannel(partedChannel);
break;
case @"chat.message.new":
Debug.Assert(message.Data != null);
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
foreach (var msg in messageData.Messages)
postToChannel(msg);
break;
}
}
private void postToChannel(Message message)
{
if (channelsMap.TryGetValue(message.ChannelId, out Channel? channel))
{
joinChannel(channel);
NewMessages?.Invoke(new List<Message> { message });
return;
}
var req = new GetChannelRequest(message.ChannelId);
req.Success += response =>
{
joinChannel(channelsMap[message.ChannelId] = response.Channel);
NewMessages?.Invoke(new List<Message> { message });
};
req.Failure += ex => Logger.Error(ex, "Failed to join channel");
api.Queue(req);
}
private void joinChannel(Channel ch)
{
ch.Joined.Value = true;
ChannelJoined?.Invoke(ch);
}
private void partChannel(Channel channel) => ChannelParted?.Invoke(channel);
private void handleMessages(List<Message>? messages)
{
if (messages == null)
return;
NewMessages?.Invoke(messages);
}
public void Dispose()
{
client.IsConnected.ValueChanged -= onConnectedChanged;
client.MessageReceived -= onMessageReceived;
}
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
namespace osu.Game.Online namespace osu.Game.Online
{ {
/// <summary> /// <summary>
@ -13,36 +11,36 @@ namespace osu.Game.Online
/// <summary> /// <summary>
/// The base URL for the website. /// The base URL for the website.
/// </summary> /// </summary>
public string WebsiteRootUrl { get; set; } public string WebsiteRootUrl { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The endpoint for the main (osu-web) API. /// The endpoint for the main (osu-web) API.
/// </summary> /// </summary>
public string APIEndpointUrl { get; set; } public string APIEndpointUrl { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The OAuth client secret. /// The OAuth client secret.
/// </summary> /// </summary>
public string APIClientSecret { get; set; } public string APIClientSecret { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The OAuth client ID. /// The OAuth client ID.
/// </summary> /// </summary>
public string APIClientID { get; set; } public string APIClientID { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The endpoint for the SignalR spectator server. /// The endpoint for the SignalR spectator server.
/// </summary> /// </summary>
public string SpectatorEndpointUrl { get; set; } public string SpectatorEndpointUrl { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The endpoint for the SignalR multiplayer server. /// The endpoint for the SignalR multiplayer server.
/// </summary> /// </summary>
public string MultiplayerEndpointUrl { get; set; } public string MultiplayerEndpointUrl { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The endpoint for the SignalR metadata server. /// The endpoint for the SignalR metadata server.
/// </summary> /// </summary>
public string MetadataEndpointUrl { get; set; } public string MetadataEndpointUrl { get; set; } = string.Empty;
} }
} }

View File

@ -1,19 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Online
{
public class ExperimentalEndpointConfiguration : EndpointConfiguration
{
public ExperimentalEndpointConfiguration()
{
WebsiteRootUrl = @"https://osu.ppy.sh";
APIEndpointUrl = @"https://lazer.ppy.sh";
APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk";
APIClientID = "5";
SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator";
MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer";
MetadataEndpointUrl = "https://spectator.ppy.sh/metadata";
}
}
}

View File

@ -1,42 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications
{
/// <summary>
/// An abstract connector or <see cref="NotificationsClient"/>s.
/// </summary>
public abstract class NotificationsClientConnector : PersistentEndpointClientConnector
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
protected NotificationsClientConnector(IAPIProvider api)
: base(api)
{
}
protected sealed override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var client = await BuildNotificationClientAsync(cancellationToken).ConfigureAwait(false);
client.ChannelJoined = c => ChannelJoined?.Invoke(c);
client.ChannelParted = c => ChannelParted?.Invoke(c);
client.NewMessages = m => NewMessages?.Invoke(m);
client.PresenceReceived = () => PresenceReceived?.Invoke();
return client;
}
protected abstract Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
public class DummyNotificationsClient : INotificationsClient
{
public IBindable<bool> IsConnected => new BindableBool(true);
public event Action<SocketMessage>? MessageReceived;
public Func<SocketMessage, bool>? HandleMessage;
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (HandleMessage?.Invoke(message) != true)
throw new InvalidOperationException($@"{nameof(DummyNotificationsClient)} cannot process this message.");
return Task.CompletedTask;
}
public void Receive(SocketMessage message) => MessageReceived?.Invoke(message);
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
/// <summary>
/// A client for asynchronous notifications sent by osu-web.
/// </summary>
public interface INotificationsClient
{
/// <summary>
/// Whether this <see cref="INotificationsClient"/> is currently connected to a server.
/// </summary>
IBindable<bool> IsConnected { get; }
/// <summary>
/// Invoked when a new <see cref="SocketMessage"/> arrives for this client.
/// </summary>
event Action<SocketMessage>? MessageReceived;
/// <summary>
/// Sends a <see cref="SocketMessage"/> to the notification server.
/// </summary>
Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default);
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using System.Net.WebSockets; using System.Net.WebSockets;
@ -12,23 +11,20 @@ using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications.WebSocket namespace osu.Game.Online.Notifications.WebSocket
{ {
/// <summary> /// <summary>
/// A notifications client which receives events via a websocket. /// A notifications client which receives events via a websocket.
/// </summary> /// </summary>
public class WebSocketNotificationsClient : NotificationsClient public class WebSocketNotificationsClient : PersistentEndpointClient
{ {
public event Action<SocketMessage>? MessageReceived;
private readonly ClientWebSocket socket; private readonly ClientWebSocket socket;
private readonly string endpoint; private readonly string endpoint;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api) public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint)
: base(api)
{ {
this.socket = socket; this.socket = socket;
this.endpoint = endpoint; this.endpoint = endpoint;
@ -37,11 +33,7 @@ namespace osu.Game.Online.Notifications.WebSocket
public override async Task ConnectAsync(CancellationToken cancellationToken) public override async Task ConnectAsync(CancellationToken cancellationToken)
{ {
await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false); await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false);
await sendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false);
runReadLoop(cancellationToken); runReadLoop(cancellationToken);
await base.ConnectAsync(cancellationToken).ConfigureAwait(false);
} }
private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () => private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () =>
@ -73,7 +65,7 @@ namespace osu.Game.Online.Notifications.WebSocket
break; break;
} }
await onMessageReceivedAsync(message).ConfigureAwait(false); MessageReceived?.Invoke(message);
} }
break; break;
@ -105,69 +97,12 @@ namespace osu.Game.Online.Notifications.WebSocket
} }
} }
private async Task sendMessage(SocketMessage message, CancellationToken cancellationToken) public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{ {
if (socket.State != WebSocketState.Open) if (socket.State != WebSocketState.Open)
return; return;
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken ?? CancellationToken.None).ConfigureAwait(false);
}
private async Task onMessageReceivedAsync(SocketMessage message)
{
switch (message.Event)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
HandleChannelJoined(joinedChannel);
break;
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
HandleChannelParted(partedChannel);
break;
case @"chat.message.new":
Debug.Assert(message.Data != null);
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
foreach (var msg in messageData.Messages)
HandleChannelJoined(await getChannel(msg.ChannelId).ConfigureAwait(false));
HandleMessages(messageData.Messages);
break;
}
}
private async Task<Channel> getChannel(long channelId)
{
if (channelsMap.TryGetValue(channelId, out Channel? channel))
return channel;
var tsc = new TaskCompletionSource<Channel>();
var req = new GetChannelRequest(channelId);
req.Success += response =>
{
channelsMap[channelId] = response.Channel;
tsc.SetResult(response.Channel);
};
req.Failure += ex => tsc.SetException(ex);
API.Queue(req);
return await tsc.Task.ConfigureAwait(false);
} }
public override async ValueTask DisposeAsync() public override async ValueTask DisposeAsync()

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Net; using System.Net;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Threading; using System.Threading;
@ -13,26 +14,26 @@ namespace osu.Game.Online.Notifications.WebSocket
/// <summary> /// <summary>
/// A connector for <see cref="WebSocketNotificationsClient"/>s that receive events via a websocket. /// A connector for <see cref="WebSocketNotificationsClient"/>s that receive events via a websocket.
/// </summary> /// </summary>
public class WebSocketNotificationsClientConnector : NotificationsClientConnector public class WebSocketNotificationsClientConnector : PersistentEndpointClientConnector, INotificationsClient
{ {
public event Action<SocketMessage>? MessageReceived;
private readonly IAPIProvider api; private readonly IAPIProvider api;
public WebSocketNotificationsClientConnector(IAPIProvider api) public WebSocketNotificationsClientConnector(IAPIProvider api)
: base(api) : base(api)
{ {
this.api = api; this.api = api;
Start();
} }
protected override async Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken) protected override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{ {
var tcs = new TaskCompletionSource<string>();
var req = new GetNotificationsRequest(); var req = new GetNotificationsRequest();
req.Success += bundle => tcs.SetResult(bundle.Endpoint); // must use `PerformAsync()`, since we may not be fully online yet
req.Failure += ex => tcs.SetException(ex); // (see `APIState.RequiresSecondFactorAuth` - in this state queued requests will not execute).
api.Queue(req); await api.PerformAsync(req).ConfigureAwait(false);
string endpoint = req.Response!.Endpoint;
string endpoint = await tcs.Task.ConfigureAwait(false);
ClientWebSocket socket = new ClientWebSocket(); ClientWebSocket socket = new ClientWebSocket();
socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}"); socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}");
@ -40,7 +41,17 @@ namespace osu.Game.Online.Notifications.WebSocket
if (socket.Options.Proxy != null) if (socket.Options.Proxy != null)
socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials; socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials;
return new WebSocketNotificationsClient(socket, endpoint, api); var client = new WebSocketNotificationsClient(socket, endpoint);
client.MessageReceived += msg => MessageReceived?.Invoke(msg);
return client;
}
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (CurrentConnection is not WebSocketNotificationsClient webSocketClient)
return Task.CompletedTask;
return webSocketClient.SendAsync(message, cancellationToken);
} }
} }
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.Screens;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Metadata; using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Notifications;
@ -25,6 +26,8 @@ namespace osu.Game.Online
{ {
private readonly Func<IScreen> getCurrentScreen; private readonly Func<IScreen> getCurrentScreen;
private INotificationsClient notificationsClient = null!;
[Resolved] [Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!; private MultiplayerClient multiplayerClient { get; set; } = null!;
@ -55,9 +58,11 @@ namespace osu.Game.Online
private void load(IAPIProvider api) private void load(IAPIProvider api)
{ {
apiState = api.State.GetBoundCopy(); apiState = api.State.GetBoundCopy();
notificationsClient = api.NotificationsClient;
multiplayerState = multiplayerClient.IsConnected.GetBoundCopy(); multiplayerState = multiplayerClient.IsConnected.GetBoundCopy();
spectatorState = spectatorClient.IsConnected.GetBoundCopy(); spectatorState = spectatorClient.IsConnected.GetBoundCopy();
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
multiplayerClient.Disconnecting += notifyAboutForcedDisconnection; multiplayerClient.Disconnecting += notifyAboutForcedDisconnection;
spectatorClient.Disconnecting += notifyAboutForcedDisconnection; spectatorClient.Disconnecting += notifyAboutForcedDisconnection;
metadataClient.Disconnecting += notifyAboutForcedDisconnection; metadataClient.Disconnecting += notifyAboutForcedDisconnection;
@ -127,10 +132,27 @@ namespace osu.Game.Online
}); });
} }
private void notifyAboutForcedDisconnection(SocketMessage obj)
{
if (obj.Event != @"logout") return;
if (userNotified) return;
userNotified = true;
notificationOverlay?.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationCircle,
Text = "You have been logged out due to a change to your account. Please log in again."
});
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (notificationsClient.IsNotNull())
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
if (spectatorClient.IsNotNull()) if (spectatorClient.IsNotNull())
spectatorClient.Disconnecting -= notifyAboutForcedDisconnection; spectatorClient.Disconnecting -= notifyAboutForcedDisconnection;

View File

@ -3,6 +3,7 @@
#nullable disable #nullable disable
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -79,10 +80,14 @@ namespace osu.Game.Online
case APIState.Failing: case APIState.Failing:
case APIState.Connecting: case APIState.Connecting:
case APIState.RequiresSecondFactorAuth:
PopContentOut(Content); PopContentOut(Content);
LoadingSpinner.Show(); LoadingSpinner.Show();
placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); placeholder.FadeOut(transform_duration / 2, Easing.OutQuint);
break; break;
default:
throw new ArgumentOutOfRangeException();
} }
}); });

View File

@ -69,6 +69,7 @@ namespace osu.Game.Online
break; break;
case APIState.Online: case APIState.Online:
case APIState.RequiresSecondFactorAuth:
await connect().ConfigureAwait(true); await connect().ConfigureAwait(true);
break; break;
} }
@ -83,7 +84,7 @@ namespace osu.Game.Online
try try
{ {
while (apiState.Value == APIState.Online) while (apiState.Value == APIState.RequiresSecondFactorAuth || apiState.Value == APIState.Online)
{ {
// ensure any previous connection was disposed. // ensure any previous connection was disposed.
// this will also create a new cancellation token source. // this will also create a new cancellation token source.

View File

@ -102,7 +102,7 @@ namespace osu.Game
public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild;
public virtual EndpointConfiguration CreateEndpoints() => public virtual EndpointConfiguration CreateEndpoints() =>
UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ExperimentalEndpointConfiguration(); UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version();
@ -340,10 +340,6 @@ namespace osu.Game
dependencies.Cache(beatmapCache = new BeatmapLookupCache()); dependencies.Cache(beatmapCache = new BeatmapLookupCache());
base.Content.Add(beatmapCache); base.Content.Add(beatmapCache);
var scorePerformanceManager = new ScorePerformanceCache();
dependencies.Cache(scorePerformanceManager);
base.Content.Add(scorePerformanceManager);
dependencies.CacheAs<IRulesetConfigCache>(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); dependencies.CacheAs<IRulesetConfigCache>(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore));
var powerStatus = CreateBatteryInfo(); var powerStatus = CreateBatteryInfo();

View File

@ -3,6 +3,7 @@
#nullable disable #nullable disable
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
@ -118,12 +119,16 @@ namespace osu.Game.Overlays
break; break;
case APIState.Connecting: case APIState.Connecting:
case APIState.RequiresSecondFactorAuth:
break; break;
case APIState.Online: case APIState.Online:
scheduledHide?.Cancel(); scheduledHide?.Cancel();
scheduledHide = Schedule(Hide); scheduledHide = Schedule(Hide);
break; break;
default:
throw new ArgumentOutOfRangeException();
} }
} }
} }

View File

@ -32,13 +32,7 @@ namespace osu.Game.Overlays.Login
public Action? RequestHide; public Action? RequestHide;
private void performLogin() public override bool AcceptsFocus => true;
{
if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text))
api.Login(username.Text, password.Text);
else
shakeSignIn.Shake();
}
[BackgroundDependencyLoader(permitNulls: true)] [BackgroundDependencyLoader(permitNulls: true)]
private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) private void load(OsuConfigManager config, AccountCreationOverlay accountCreation)
@ -144,7 +138,13 @@ namespace osu.Game.Overlays.Login
} }
} }
public override bool AcceptsFocus => true; private void performLogin()
{
if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text))
api.Login(username.Text, password.Text);
else
shakeSignIn.Shake();
}
protected override bool OnClick(ClickEvent e) => true; protected override bool OnClick(ClickEvent e) => true;

View File

@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Login
{ {
private bool bounding = true; private bool bounding = true;
private LoginForm? form; private Drawable? form;
[Resolved] [Resolved]
private OsuColour colours { get; set; } = null!; private OsuColour colours { get; set; } = null!;
@ -81,6 +81,10 @@ namespace osu.Game.Overlays.Login
}; };
break; break;
case APIState.RequiresSecondFactorAuth:
Child = form = new SecondFactorAuthForm();
break;
case APIState.Failing: case APIState.Failing:
case APIState.Connecting: case APIState.Connecting:
LinkFlowContainer linkFlow; LinkFlowContainer linkFlow;

View File

@ -0,0 +1,147 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Settings;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Login
{
public partial class SecondFactorAuthForm : Container
{
private OsuTextBox codeTextBox = null!;
private LinkFlowContainer explainText = null!;
private ErrorTextFlowContainer errorText = null!;
private LoadingLayer loading = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = "An email has been sent to you with a verification code. Enter the code.",
},
codeTextBox = new OsuTextBox
{
PlaceholderText = "Enter code",
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
},
explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
errorText = new ErrorTextFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
},
},
},
new LinkFlowContainer
{
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
}
},
loading = new LoadingLayer(true)
{
Padding = new MarginPadding { Vertical = -SettingsSection.ITEM_SPACING },
}
};
explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam);
// We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something).
explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the ");
explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset");
explainText.AddText(". You can also ");
explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () =>
{
loading.Show();
var reissueRequest = new ReissueVerificationCodeRequest();
reissueRequest.Failure += ex =>
{
Logger.Error(ex, @"Failed to retrieve new verification code.");
loading.Hide();
};
reissueRequest.Success += () =>
{
loading.Hide();
};
Task.Run(() => api.Perform(reissueRequest));
});
explainText.AddText(" or ");
explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); });
explainText.AddText(".");
codeTextBox.Current.BindValueChanged(code =>
{
if (code.NewValue.Length == 8)
{
api.AuthenticateSecondFactor(code.NewValue);
codeTextBox.Current.Disabled = true;
}
});
if (api.LastLoginError?.Message is string error)
{
errorText.Alpha = 1;
errorText.AddErrors(new[] { error });
}
}
public override bool AcceptsFocus => true;
protected override bool OnClick(ClickEvent e) => true;
protected override void OnFocus(FocusEvent e)
{
Schedule(() => { GetContainingInputManager().ChangeFocus(codeTextBox); });
}
}
}

View File

@ -99,6 +99,7 @@ namespace osu.Game.Overlays.Toolbar
switch (state.NewValue) switch (state.NewValue)
{ {
case APIState.RequiresSecondFactorAuth:
case APIState.Connecting: case APIState.Connecting:
TooltipText = ToolbarStrings.Connecting; TooltipText = ToolbarStrings.Connecting;
spinner.Show(); spinner.Show();

View File

@ -21,21 +21,29 @@ namespace osu.Game.Rulesets.Difficulty
{ {
private readonly IBeatmap playableBeatmap; private readonly IBeatmap playableBeatmap;
private readonly BeatmapDifficultyCache difficultyCache; private readonly BeatmapDifficultyCache difficultyCache;
private readonly ScorePerformanceCache performanceCache;
public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache, ScorePerformanceCache performanceCache) public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache)
{ {
this.playableBeatmap = playableBeatmap; this.playableBeatmap = playableBeatmap;
this.difficultyCache = difficultyCache; this.difficultyCache = difficultyCache;
this.performanceCache = performanceCache;
} }
[ItemCanBeNull] [ItemCanBeNull]
public async Task<PerformanceBreakdown> CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default) public async Task<PerformanceBreakdown> CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default)
{ {
var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false);
var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator();
// Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value.
if (attributes?.Attributes == null || performanceCalculator == null)
return null;
cancellationToken.ThrowIfCancellationRequested();
PerformanceAttributes[] performanceArray = await Task.WhenAll( PerformanceAttributes[] performanceArray = await Task.WhenAll(
// compute actual performance // compute actual performance
performanceCache.CalculatePerformanceAsync(score, cancellationToken), performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken),
// compute performance for perfect play // compute performance for perfect play
getPerfectPerformance(score, cancellationToken) getPerfectPerformance(score, cancellationToken)
).ConfigureAwait(false); ).ConfigureAwait(false);
@ -88,8 +96,12 @@ namespace osu.Game.Rulesets.Difficulty
cancellationToken cancellationToken
).ConfigureAwait(false); ).ConfigureAwait(false);
// ScorePerformanceCache is not used to avoid caching multiple copies of essentially identical perfect performance attributes var performanceCalculator = ruleset.CreatePerformanceCalculator();
return difficulty == null ? null : ruleset.CreatePerformanceCalculator()?.Calculate(perfectPlay, difficulty.Value.Attributes.AsNonNull());
if (performanceCalculator == null || difficulty == null)
return null;
return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false);
}, cancellationToken); }, cancellationToken);
} }

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Scoring; using osu.Game.Scoring;
@ -15,6 +17,9 @@ namespace osu.Game.Rulesets.Difficulty
Ruleset = ruleset; Ruleset = ruleset;
} }
public Task<PerformanceAttributes> CalculateAsync(ScoreInfo score, DifficultyAttributes attributes, CancellationToken cancellationToken)
=> Task.Run(() => CreatePerformanceAttributes(score, attributes), cancellationToken);
public PerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes) public PerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes)
=> CreatePerformanceAttributes(score, attributes); => CreatePerformanceAttributes(score, attributes);

View File

@ -10,7 +10,7 @@ using osuTK;
namespace osu.Game.Rulesets.Judgements namespace osu.Game.Rulesets.Judgements
{ {
public partial class DefaultJudgementPiece : JudgementPiece, IAnimatableJudgement public partial class DefaultJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{ {
public DefaultJudgementPiece(HitResult result) public DefaultJudgementPiece(HitResult result)
: base(result) : base(result)
@ -38,20 +38,6 @@ namespace osu.Game.Rulesets.Judgements
/// </remarks> /// </remarks>
public virtual void PlayAnimation() public virtual void PlayAnimation()
{ {
// TODO: make these better. currently they are using a text `-` and it's not centered properly.
// Should be an explicit drawable.
//
// When this is done, remove the [Description] attributes from HitResults which were added for this purpose.
if (Result == HitResult.IgnoreMiss || Result == HitResult.LargeTickMiss)
{
this.RotateTo(-45);
this.ScaleTo(1.6f);
this.ScaleTo(1.2f, 100, Easing.In);
this.FadeOutFromOne(400);
return;
}
if (Result.IsMiss()) if (Result.IsMiss())
{ {
this.ScaleTo(1.6f); this.ScaleTo(1.6f);

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Judgements namespace osu.Game.Rulesets.Judgements
{ {
public abstract partial class JudgementPiece : CompositeDrawable public abstract partial class TextJudgementPiece : CompositeDrawable
{ {
protected readonly HitResult Result; protected readonly HitResult Result;
@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Judgements
[Resolved] [Resolved]
private OsuColour colours { get; set; } = null!; private OsuColour colours { get; set; } = null!;
protected JudgementPiece(HitResult result) protected TextJudgementPiece(HitResult result)
{ {
Result = result; Result = result;
} }

View File

@ -768,6 +768,13 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (CurrentSkin != null) if (CurrentSkin != null)
CurrentSkin.SourceChanged -= skinSourceChanged; CurrentSkin.SourceChanged -= skinSourceChanged;
// Safeties against shooting in foot in cases where these are bound by external entities (like playfield) that don't clean up.
OnNestedDrawableCreated = null;
OnNewResult = null;
OnRevertResult = null;
DefaultsApplied = null;
HitObjectApplied = null;
} }
public Bindable<double> AnimationStartTime { get; } = new BindableDouble(); public Bindable<double> AnimationStartTime { get; } = new BindableDouble();

Some files were not shown because too many files have changed in this diff Show More