1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 11:37:28 +08:00

Merge branch 'master' into dont-round-sv

This commit is contained in:
Bartłomiej Dach 2024-01-29 11:52:46 +01:00
commit 11ec8a1cad
No known key found for this signature in database
118 changed files with 1890 additions and 600 deletions

View File

@ -35,7 +35,7 @@
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<PackageReleaseNotes>Automated release.</PackageReleaseNotes>
<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>
</PropertyGroup>
</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
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).
- 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!

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageType>Template</PackageType>
<PackageId>ppy.osu.Game.Templates</PackageId>
@ -8,7 +8,7 @@
<PackageProjectUrl>https://github.com/ppy/osu/blob/master/Templates</PackageProjectUrl>
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<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>
<PackageTags>dotnet-new;templates;osu</PackageTags>
<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>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.114.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.127.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<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 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<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>
<owners>Dean Herbert</owners>
<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>
<description>A free-to-win rhythm game. Rhythm is just a *click* away!</description>
<releaseNotes>testing</releaseNotes>
<copyright>Copyright (c) 2022 ppy Pty Ltd</copyright>
<copyright>Copyright (c) 2024 ppy Pty Ltd</copyright>
<language>en-AU</language>
</metadata>
<files>

View File

@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu
base.ReloadMappings(realmKeyBindings);
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

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

View File

@ -169,9 +169,9 @@ namespace osu.Game.Tests.NonVisual.Skinning
public IRenderer Renderer => new DummyRenderer();
public AudioManager AudioManager => null;
public IResourceStore<byte[]> Files => null;
public IResourceStore<byte[]> Resources => null;
public RealmAccess RealmAccess => null;
public IResourceStore<byte[]> Files => null!;
public IResourceStore<byte[]> Resources => null!;
public RealmAccess RealmAccess => null!;
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());
AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true);
AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100);
AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0);
AddAssert("score has combo", () => getResultsScreen().Score!.Combo > 100);
AddAssert("score has no misses", () => getResultsScreen().Score!.Statistics[HitResult.Miss] == 0);
AddUntilStep("avatar displayed", () => getAvatar() != 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);
// 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 matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(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()));
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()));
@ -184,7 +184,11 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest();
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()));

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Net;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -9,7 +10,9 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays;
using osu.Game.Overlays.Login;
using osu.Game.Users.Drawables;
using osuTK.Input;
@ -18,6 +21,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture]
public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene
{
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private LoginOverlay loginOverlay = null!;
[BackgroundDependencyLoader]
@ -40,9 +45,69 @@ namespace osu.Game.Tests.Visual.Menus
public void TestLoginSuccess()
{
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());
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]
@ -78,6 +143,12 @@ namespace osu.Game.Tests.Visual.Menus
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("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
assertAPIState(APIState.Online);
AddStep("click on flag", () =>
{
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<UpdateableFlag>().First());

View File

@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture]
public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene
{
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
public TestSceneToolbarUserButton()
{
Container mainContainer;
@ -69,18 +71,20 @@ namespace osu.Game.Tests.Visual.Menus
[Test]
public void TestLoginLogout()
{
AddStep("Log out", () => ((DummyAPIAccess)API).Logout());
AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang"));
AddStep("Log out", () => dummyAPI.Logout());
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh"));
}
[Test]
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>())
{
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;
return !scoreInfo.Passed && scoreInfo.Rank == ScoreRank.F;
return scoreInfo?.Passed == false && scoreInfo.Rank == ScoreRank.F;
});
}

View File

@ -3,13 +3,18 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
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.Play;
@ -19,14 +24,37 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private MultiplayerPlayer player;
[SetUpSteps]
public override void SetUpSteps()
[Test]
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", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
SelectedMods.Value = mods?.Invoke() ?? Array.Empty<Mod>();
});
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);
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:
AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen);
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));
break;

View File

@ -10,6 +10,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.AccountCreation;
@ -59,7 +60,11 @@ namespace osu.Game.Tests.Visual.Online
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick());
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);
}
}

View File

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

View File

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

View File

@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online
else
{
int userId = int.Parse(getUserRequest.Lookup);
string rulesetName = getUserRequest.Ruleset.ShortName;
string rulesetName = getUserRequest.Ruleset!.ShortName;
var response = new APIUser
{
Id = userId,
@ -177,7 +177,11 @@ namespace osu.Game.Tests.Visual.Online
AddWaitStep("wait a bit", 5);
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]

View File

@ -52,7 +52,11 @@ namespace osu.Game.Tests.Visual.Online
AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 }));
AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden);
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]
@ -98,7 +102,11 @@ namespace osu.Game.Tests.Visual.Online
});
AddStep("logout", () => dummyAPI.Logout());
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);
AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER));
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Game.Overlays;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API;
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);
}
private void logIn() => API.Login("localUser", "password");
private void logIn()
{
API.Login("localUser", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
}
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 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)
{
}

View File

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

View File

@ -125,7 +125,11 @@ namespace osu.Game.Tests.Visual.UserInterface
assertLoggedOutState();
// 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();
}

View File

@ -1,17 +1,17 @@
// 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.Utils;
using osuTK;
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 osu.Framework.Graphics.Rendering;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
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
{
@ -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 ScaleAdjust { get; set; } = 1;
/// <summary>
/// Whether we should create new triangles as others expire.
/// </summary>
@ -106,7 +108,7 @@ namespace osu.Game.Graphics.Backgrounds
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)
parts.RemoveAt(i);
}
@ -149,7 +151,7 @@ namespace osu.Game.Graphics.Backgrounds
if (randomY)
{
// 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);
}
@ -188,7 +190,7 @@ namespace osu.Game.Graphics.Backgrounds
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 float thickness;
@ -209,6 +211,7 @@ namespace osu.Game.Graphics.Backgrounds
size = Source.DrawSize;
thickness = Source.Thickness;
clampAxes = Source.ClampAxes;
triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size * Source.ScaleAdjust;
Quad triangleQuad = new Quad(
Vector2Extensions.Transform(Vector2.Zero, DrawInfo.Matrix),

View File

@ -21,7 +21,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
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;
@ -48,6 +48,8 @@ namespace osu.Game.Online.API
public string ProvidedUsername { get; private set; }
public string SecondFactorCode { get; private set; }
private string password;
public IBindable<APIUser> LocalUser => localUser;
@ -55,6 +57,8 @@ namespace osu.Game.Online.API
public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics;
public INotificationsClient NotificationsClient { get; }
public Language Language => game.CurrentLanguage.Value;
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
@ -82,6 +86,7 @@ namespace osu.Game.Online.API
APIEndpointUrl = endpointConfiguration.APIEndpointUrl;
WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl;
NotificationsClient = setUpNotificationsClient();
authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl);
log = Logger.GetLogger(LoggingTarget.Network);
@ -114,6 +119,30 @@ namespace osu.Game.Online.API
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);
internal new void Schedule(Action action) => base.Schedule(action);
@ -197,6 +226,7 @@ namespace osu.Game.Online.API
/// </summary>
/// <remarks>
/// 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.Failing"/> (failed connection but retrying)
/// - <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>
private void attemptConnect()
{
state.Value = APIState.Connecting;
if (localUser.IsDefault)
{
// Show a placeholder user if saved credentials are available.
@ -223,6 +251,7 @@ namespace osu.Game.Online.API
if (!authentication.HasValidAccessToken)
{
state.Value = APIState.Connecting;
LastLoginError = null;
try
@ -240,40 +269,79 @@ namespace osu.Game.Online.API
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
switch (state.Value)
{
if (ex is APIException)
case APIState.RequiresSecondFactorAuth:
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
if (string.IsNullOrEmpty(SecondFactorCode))
return;
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");
Logout();
var userReq = new GetMeRequest();
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();
@ -321,11 +389,17 @@ namespace osu.Game.Online.API
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) =>
new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack);
public NotificationsClientConnector GetNotificationsConnector() =>
new WebSocketNotificationsClientConnector(this);
public IChatClient GetChatClient() => new WebSocketChatClient(this);
public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password)
{
@ -507,6 +581,7 @@ namespace osu.Game.Online.API
public void Logout()
{
password = null;
SecondFactorCode = null;
authentication.Clear();
// 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>
Failing,
/// <summary>
/// Waiting on second factor authentication.
/// </summary>
RequiresSecondFactorAuth,
/// <summary>
/// We are in the process of (re-)connecting.
/// </summary>

View File

@ -7,8 +7,10 @@ using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
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.Users;
@ -30,6 +32,9 @@ namespace osu.Game.Online.API
public Bindable<UserStatistics?> Statistics { get; } = new Bindable<UserStatistics?>();
public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient();
INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient;
public Language Language => Language.en;
public string AccessToken => "token";
@ -57,6 +62,7 @@ namespace osu.Game.Online.API
private bool shouldFailNextLogin;
private bool stayConnectingNextLogin;
private bool requiredSecondFactorAuth = true;
/// <summary>
/// The current connectivity state of the API.
@ -117,13 +123,46 @@ namespace osu.Game.Online.API
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
{
GlobalRank = 1,
CountryRank = 1
};
state.Value = APIState.Online;
}
public void Logout()
@ -144,7 +183,7 @@ namespace osu.Game.Online.API
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)
{
@ -159,6 +198,11 @@ namespace osu.Game.Online.API
IBindable<UserActivity> IAPIProvider.Activity => Activity;
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;
/// <summary>
/// Skip 2FA requirement for next login.
/// </summary>
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
/// <summary>
/// During the next simulated login, the process will fail immediately.
/// </summary>

View File

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

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,8 +1,6 @@
// 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.
#nullable disable
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
@ -11,24 +9,17 @@ namespace osu.Game.Online.API.Requests
public class GetUserRequest : APIRequest<APIUser>
{
public readonly string Lookup;
public readonly IRulesetInfo Ruleset;
public readonly IRulesetInfo? Ruleset;
private readonly LookupType lookupType;
/// <summary>
/// Gets the currently logged-in user.
/// </summary>
public GetUserRequest()
{
}
/// <summary>
/// Gets a user from their ID.
/// </summary>
/// <param name="userId">The user to get.</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;
Ruleset = ruleset;
}
@ -38,14 +29,14 @@ namespace osu.Game.Online.API.Requests
/// </summary>
/// <param name="username">The user to get.</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;
lookupType = LookupType.Username;
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
{

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.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Online.Chat
@ -64,13 +63,8 @@ namespace osu.Game.Online.Chat
/// </summary>
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 NotificationsClientConnector connector;
private readonly IChatClient chatClient;
[Resolved]
private UserLookupCache users { get; set; }
@ -85,7 +79,7 @@ namespace osu.Game.Online.Chat
{
this.api = api;
connector = api.GetNotificationsConnector();
chatClient = api.GetChatClient();
CurrentChannel.ValueChanged += currentChannelChanged;
}
@ -93,15 +87,11 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader]
private void load()
{
connector.ChannelJoined += ch => Schedule(() => joinChannel(ch));
connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
connector.NewMessages += msgs => Schedule(() => addMessages(msgs));
connector.PresenceReceived += () => Schedule(initializeChannels);
connector.Start();
chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch));
chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs));
chatClient.PresenceReceived += () => Schedule(initializeChannels);
chatClient.RequestPresence();
apiState.BindTo(api.State);
apiState.BindValueChanged(_ => SendAck(), true);
@ -655,7 +645,7 @@ namespace osu.Game.Online.Chat
protected override void Dispose(bool 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,144 @@
// 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 Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
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>();
public WebSocketChatClient(IAPIProvider api)
{
this.api = api;
client = api.NotificationsClient;
client.IsConnected.BindValueChanged(start, true);
}
private void start(ValueChangedEvent<bool> connected)
{
if (!connected.NewValue)
return;
client.MessageReceived += onMessageReceived;
client.SendAsync(new StartChatRequest()).WaitSafely();
RequestPresence();
}
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 -= start;
client.MessageReceived -= onMessageReceived;
}
}
}

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.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
@ -12,23 +11,20 @@ using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;
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
{
/// <summary>
/// A notifications client which receives events via a websocket.
/// </summary>
public class WebSocketNotificationsClient : NotificationsClient
public class WebSocketNotificationsClient : PersistentEndpointClient
{
public event Action<SocketMessage>? MessageReceived;
private readonly ClientWebSocket socket;
private readonly string endpoint;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api)
: base(api)
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint)
{
this.socket = socket;
this.endpoint = endpoint;
@ -37,11 +33,7 @@ namespace osu.Game.Online.Notifications.WebSocket
public override async Task ConnectAsync(CancellationToken cancellationToken)
{
await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false);
await sendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false);
runReadLoop(cancellationToken);
await base.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () =>
@ -73,7 +65,7 @@ namespace osu.Game.Online.Notifications.WebSocket
break;
}
await onMessageReceivedAsync(message).ConfigureAwait(false);
MessageReceived?.Invoke(message);
}
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)
return;
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken).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);
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken ?? CancellationToken.None).ConfigureAwait(false);
}
public override async ValueTask DisposeAsync()

View File

@ -1,6 +1,7 @@
// 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.Net;
using System.Net.WebSockets;
using System.Threading;
@ -13,17 +14,20 @@ namespace osu.Game.Online.Notifications.WebSocket
/// <summary>
/// A connector for <see cref="WebSocketNotificationsClient"/>s that receive events via a websocket.
/// </summary>
public class WebSocketNotificationsClientConnector : NotificationsClientConnector
public class WebSocketNotificationsClientConnector : PersistentEndpointClientConnector, INotificationsClient
{
public event Action<SocketMessage>? MessageReceived;
private readonly IAPIProvider api;
public WebSocketNotificationsClientConnector(IAPIProvider api)
: base(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>();
@ -40,7 +44,17 @@ namespace osu.Game.Online.Notifications.WebSocket
if (socket.Options.Proxy != null)
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.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
@ -25,6 +26,8 @@ namespace osu.Game.Online
{
private readonly Func<IScreen> getCurrentScreen;
private INotificationsClient notificationsClient = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
@ -55,9 +58,11 @@ namespace osu.Game.Online
private void load(IAPIProvider api)
{
apiState = api.State.GetBoundCopy();
notificationsClient = api.NotificationsClient;
multiplayerState = multiplayerClient.IsConnected.GetBoundCopy();
spectatorState = spectatorClient.IsConnected.GetBoundCopy();
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
multiplayerClient.Disconnecting += notifyAboutForcedDisconnection;
spectatorClient.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)
{
base.Dispose(isDisposing);
if (notificationsClient.IsNotNull())
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
if (spectatorClient.IsNotNull())
spectatorClient.Disconnecting -= notifyAboutForcedDisconnection;

View File

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

View File

@ -340,10 +340,6 @@ namespace osu.Game
dependencies.Cache(beatmapCache = new BeatmapLookupCache());
base.Content.Add(beatmapCache);
var scorePerformanceManager = new ScorePerformanceCache();
dependencies.Cache(scorePerformanceManager);
base.Content.Add(scorePerformanceManager);
dependencies.CacheAs<IRulesetConfigCache>(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore));
var powerStatus = CreateBatteryInfo();

View File

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

View File

@ -32,13 +32,7 @@ namespace osu.Game.Overlays.Login
public Action? RequestHide;
private void performLogin()
{
if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text))
api.Login(username.Text, password.Text);
else
shakeSignIn.Shake();
}
public override bool AcceptsFocus => true;
[BackgroundDependencyLoader(permitNulls: true)]
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;

View File

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

View File

@ -21,21 +21,29 @@ namespace osu.Game.Rulesets.Difficulty
{
private readonly IBeatmap playableBeatmap;
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.difficultyCache = difficultyCache;
this.performanceCache = performanceCache;
}
[ItemCanBeNull]
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(
// compute actual performance
performanceCache.CalculatePerformanceAsync(score, cancellationToken),
performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken),
// compute performance for perfect play
getPerfectPerformance(score, cancellationToken)
).ConfigureAwait(false);
@ -88,8 +96,12 @@ namespace osu.Game.Rulesets.Difficulty
cancellationToken
).ConfigureAwait(false);
// ScorePerformanceCache is not used to avoid caching multiple copies of essentially identical perfect performance attributes
return difficulty == null ? null : ruleset.CreatePerformanceCalculator()?.Calculate(perfectPlay, difficulty.Value.Attributes.AsNonNull());
var performanceCalculator = ruleset.CreatePerformanceCalculator();
if (performanceCalculator == null || difficulty == null)
return null;
return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false);
}, cancellationToken);
}

View File

@ -1,6 +1,8 @@
// 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;
using System.Threading.Tasks;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
@ -15,6 +17,9 @@ namespace osu.Game.Rulesets.Difficulty
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)
=> CreatePerformanceAttributes(score, attributes);

View File

@ -768,6 +768,13 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (CurrentSkin != null)
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();

View File

@ -38,6 +38,8 @@ namespace osu.Game.Rulesets.Objects
/// <summary>
/// Invoked after <see cref="ApplyDefaults"/> has completed on this <see cref="HitObject"/>.
/// </summary>
// TODO: This has no implicit unbind flow. Currently, if a Playfield manages HitObjects it will leave a bound event on this and cause the
// playfield to remain in memory.
public event Action<HitObject> DefaultsApplied;
public readonly Bindable<double> StartTimeBindable = new BindableDouble();

View File

@ -181,6 +181,8 @@ namespace osu.Game.Rulesets.Scoring
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
private HitObject? lastHitObject;
public bool ApplyNewJudgementsWhenFailed { get; set; }
public ScoreProcessor(Ruleset ruleset)
{
Ruleset = ruleset;
@ -211,7 +213,7 @@ namespace osu.Game.Rulesets.Scoring
result.ComboAtJudgement = Combo.Value;
result.HighestComboAtJudgement = HighestCombo.Value;
if (result.FailedAtJudgement)
if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed)
return;
ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1;
@ -267,7 +269,7 @@ namespace osu.Game.Rulesets.Scoring
Combo.Value = result.ComboAtJudgement;
HighestCombo.Value = result.HighestComboAtJudgement;
if (result.FailedAtJudgement)
if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed)
return;
ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1;

View File

@ -218,7 +218,7 @@ namespace osu.Game.Rulesets.UI
{
base.ReloadMappings(realmKeyBindings);
KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
KeyBindings = KeyBindings.Where(static b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
RealmKeyBindingStore.ClearDuplicateBindings(KeyBindings);
}
}

View File

@ -1,68 +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.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Scoring
{
/// <summary>
/// A component which performs and acts as a central cache for performance calculations of locally databased scores.
/// Currently not persisted between game sessions.
/// </summary>
public partial class ScorePerformanceCache : MemoryCachingComponent<ScorePerformanceCache.PerformanceCacheLookup, PerformanceAttributes>
{
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; } = null!;
protected override bool CacheNullValues => false;
/// <summary>
/// Calculates performance for the given <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The score to do the calculation on. </param>
/// <param name="token">An optional <see cref="CancellationToken"/> to cancel the operation.</param>
public Task<PerformanceAttributes?> CalculatePerformanceAsync(ScoreInfo score, CancellationToken token = default) =>
GetAsync(new PerformanceCacheLookup(score), token);
protected override async Task<PerformanceAttributes?> ComputeValueAsync(PerformanceCacheLookup lookup, CancellationToken token = default)
{
var score = lookup.ScoreInfo;
var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, token).ConfigureAwait(false);
// Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value.
if (attributes?.Attributes == null)
return null;
token.ThrowIfCancellationRequested();
return score.Ruleset.CreateInstance().CreatePerformanceCalculator()?.Calculate(score, attributes.Value.Attributes);
}
public readonly struct PerformanceCacheLookup
{
public readonly ScoreInfo ScoreInfo;
public PerformanceCacheLookup(ScoreInfo info)
{
ScoreInfo = info;
}
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(ScoreInfo.Hash);
hash.Add(ScoreInfo.ID);
return hash.ToHashCode();
}
}
}
}

View File

@ -10,6 +10,7 @@ using osu.Framework.Audio.Sample;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
@ -31,15 +32,13 @@ namespace osu.Game.Screens.Menu
/// </summary>
public partial class OsuLogo : BeatSyncedContainer
{
public readonly Color4 OsuPink = Color4Extensions.FromHex(@"e967a1");
private const double transition_length = 300;
/// <summary>
/// The osu! logo sprite has a shadow included in its texture.
/// This adjustment vector is used to match the precise edge of the border of the logo.
/// </summary>
public static readonly Vector2 SCALE_ADJUST = new Vector2(0.96f);
public static readonly Vector2 SCALE_ADJUST = new Vector2(0.94f);
private readonly Sprite logo;
private readonly CircularContainer logoContainer;
@ -58,7 +57,7 @@ namespace osu.Game.Screens.Menu
private Sample sampleDownbeat;
private readonly Container colourAndTriangles;
private readonly Triangles triangles;
private readonly TrianglesV2 triangles;
/// <summary>
/// Return value decides whether the logo should play its own sample for the click action.
@ -184,13 +183,16 @@ namespace osu.Game.Screens.Menu
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = OsuPink,
Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ff66ab"), Color4Extensions.FromHex(@"cc5289")),
},
triangles = new Triangles
triangles = new TrianglesV2
{
TriangleScale = 4,
ColourLight = Color4Extensions.FromHex(@"ff7db7"),
ColourDark = Color4Extensions.FromHex(@"de5b95"),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Thickness = 0.009f,
ScaleAdjust = 3,
SpawnRatio = 1.4f,
Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ff66ab"), Color4Extensions.FromHex(@"b6346f")),
RelativeSizeAxes = Axes.Both,
},
}

View File

@ -67,6 +67,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!LoadedBeatmapSuccessfully)
return;
ScoreProcessor.ApplyNewJudgementsWhenFailed = true;
LoadComponentAsync(new GameplayChatDisplay(Room)
{
Expanded = { BindTarget = LeaderboardExpandedState },

View File

@ -41,7 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
[Resolved]
private RulesetStore rulesets { get; set; }
public PlaylistsResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true)
public PlaylistsResultsScreen([CanBeNull] ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true)
: base(score, allowRetry, allowWatchingReplay)
{
this.roomId = roomId;

View File

@ -114,12 +114,7 @@ namespace osu.Game.Screens.Play.HUD
protected override void UpdateProgress(double progress, bool isIntro)
{
bar.TrackTime = GameplayClock.CurrentTime;
if (isIntro)
bar.CurrentTime = 0;
else
bar.CurrentTime = FrameStableClock.CurrentTime;
bar.Progress = isIntro ? 0 : progress;
}
}
}

View File

@ -3,96 +3,59 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osuTK;
namespace osu.Game.Screens.Play.HUD
{
public partial class ArgonSongProgressBar : SliderBar<double>
public partial class ArgonSongProgressBar : SongProgressBar
{
public Action<double>? OnSeek { get; set; }
// Parent will handle restricting the area of valid input.
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
private readonly float barHeight;
private readonly RoundedBar playfieldBar;
private readonly RoundedBar catchupBar;
private readonly RoundedBar audioBar;
private readonly Box background;
private readonly ColourInfo mainColour;
private ColourInfo catchUpColour;
public double StartTime
{
private get => CurrentNumber.MinValue;
set => CurrentNumber.MinValue = value;
}
public double Progress { get; set; }
public double EndTime
{
private get => CurrentNumber.MaxValue;
set => CurrentNumber.MaxValue = value;
}
public double CurrentTime
{
private get => CurrentNumber.Value;
set => CurrentNumber.Value = value;
}
public double TrackTime
{
private get => currentTrackTime.Value;
set => currentTrackTime.Value = value;
}
private double length => EndTime - StartTime;
private readonly BindableNumber<double> currentTrackTime;
public bool Interactive { get; set; }
private double trackTime => (EndTime - StartTime) * Progress;
public ArgonSongProgressBar(float barHeight)
{
currentTrackTime = new BindableDouble();
setupAlternateValue();
StartTime = 0;
EndTime = 1;
RelativeSizeAxes = Axes.X;
Height = this.barHeight = barHeight;
CornerRadius = 5;
Masking = true;
Children = new Drawable[]
InternalChildren = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = OsuColour.Gray(0.2f),
Depth = float.MaxValue,
},
catchupBar = new RoundedBar
audioBar = new RoundedBar
{
Name = "Audio bar",
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
CornerRadius = 5,
AlwaysPresent = true,
RelativeSizeAxes = Axes.Both
},
playfieldBar = new RoundedBar
@ -107,24 +70,6 @@ namespace osu.Game.Screens.Play.HUD
};
}
private void setupAlternateValue()
{
CurrentNumber.MaxValueChanged += v => currentTrackTime.MaxValue = v;
CurrentNumber.MinValueChanged += v => currentTrackTime.MinValue = v;
CurrentNumber.PrecisionChanged += v => currentTrackTime.Precision = v;
}
private float normalizedReference
{
get
{
if (EndTime - StartTime == 0)
return 1;
return (float)((TrackTime - StartTime) / length);
}
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
@ -153,47 +98,28 @@ namespace osu.Game.Screens.Play.HUD
base.OnHoverLost(e);
}
protected override void UpdateValue(float value)
{
// Handled in Update
}
protected override void Update()
{
base.Update();
playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, NormalizedValue, Math.Clamp(Time.Elapsed / 40, 0, 1));
catchupBar.Length = (float)Interpolation.Lerp(catchupBar.Length, normalizedReference, Math.Clamp(Time.Elapsed / 40, 0, 1));
playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, Progress, Math.Clamp(Time.Elapsed / 40, 0, 1));
audioBar.Length = (float)Interpolation.Lerp(audioBar.Length, AudioProgress, Math.Clamp(Time.Elapsed / 40, 0, 1));
if (TrackTime < CurrentTime)
ChangeChildDepth(catchupBar, -1);
if (trackTime > AudioTime)
ChangeInternalChildDepth(audioBar, -1);
else
ChangeChildDepth(catchupBar, 0);
ChangeInternalChildDepth(audioBar, 1);
float timeDelta = (float)(Math.Abs(CurrentTime - TrackTime));
float timeDelta = (float)Math.Abs(AudioTime - trackTime);
const float colour_transition_threshold = 20000;
catchupBar.AccentColour = Interpolation.ValueAt(
audioBar.AccentColour = Interpolation.ValueAt(
Math.Min(timeDelta, colour_transition_threshold),
mainColour,
catchUpColour,
0, colour_transition_threshold,
Easing.OutQuint);
catchupBar.Alpha = Math.Max(1, catchupBar.Length);
}
private ScheduledDelegate? scheduledSeek;
protected override void OnUserChange(double value)
{
scheduledSeek?.Cancel();
scheduledSeek = Schedule(() =>
{
if (Interactive)
OnSeek?.Invoke(value);
});
}
private partial class RoundedBar : Container

View File

@ -98,12 +98,7 @@ namespace osu.Game.Screens.Play.HUD
protected override void UpdateProgress(double progress, bool isIntro)
{
bar.CurrentTime = GameplayClock.CurrentTime;
if (isIntro)
graph.Progress = 0;
else
graph.Progress = (int)(graph.ColumnCount * progress);
graph.Progress = isIntro ? 0 : (int)(graph.ColumnCount * progress);
}
protected override void Update()

View File

@ -7,71 +7,27 @@ using osuTK.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Utils;
using osu.Framework.Threading;
namespace osu.Game.Screens.Play.HUD
{
public partial class DefaultSongProgressBar : SliderBar<double>
public partial class DefaultSongProgressBar : SongProgressBar
{
/// <summary>
/// Action which is invoked when a seek is requested, with the proposed millisecond value for the seek operation.
/// </summary>
public Action<double>? OnSeek { get; set; }
/// <summary>
/// Whether the progress bar should allow interaction, ie. to perform seek operations.
/// </summary>
public bool Interactive
{
get => showHandle;
set
{
if (value == showHandle)
return;
showHandle = value;
handleBase.FadeTo(showHandle ? 1 : 0, 200);
}
}
public Color4 FillColour
{
set => fill.Colour = value;
}
public double StartTime
{
set => CurrentNumber.MinValue = value;
}
public double EndTime
{
set => CurrentNumber.MaxValue = value;
}
public double CurrentTime
{
set => CurrentNumber.Value = value;
}
private readonly Box fill;
private readonly Container handleBase;
private readonly Container handleContainer;
private bool showHandle;
public DefaultSongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize)
{
CurrentNumber.MinValue = 0;
CurrentNumber.MaxValue = 1;
RelativeSizeAxes = Axes.X;
Height = barHeight + handleBarHeight + handleSize.Y;
Children = new Drawable[]
InternalChildren = new Drawable[]
{
new Box
{
@ -130,9 +86,14 @@ namespace osu.Game.Screens.Play.HUD
};
}
protected override void UpdateValue(float value)
public override bool Interactive
{
// handled in update
get => base.Interactive;
set
{
base.Interactive = value;
handleBase.FadeTo(value ? 1 : 0, 200);
}
}
protected override void Update()
@ -140,22 +101,10 @@ namespace osu.Game.Screens.Play.HUD
base.Update();
handleBase.Height = Height - handleContainer.Height;
float newX = (float)Interpolation.Lerp(handleBase.X, NormalizedValue * UsableWidth, Math.Clamp(Time.Elapsed / 40, 0, 1));
float newX = (float)Interpolation.Lerp(handleBase.X, AudioProgress * DrawWidth, Math.Clamp(Time.Elapsed / 40, 0, 1));
fill.Width = newX;
handleBase.X = newX;
}
private ScheduledDelegate? scheduledSeek;
protected override void OnUserChange(double value)
{
scheduledSeek?.Cancel();
scheduledSeek = Schedule(() =>
{
if (showHandle)
OnSeek?.Invoke(value);
});
}
}
}

View File

@ -1,6 +1,7 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -70,7 +71,13 @@ namespace osu.Game.Screens.Play.HUD
protected double LastHitTime { get; private set; }
/// <summary>
/// Called every update frame with current progress information.
/// </summary>
/// <param name="progress">Current (visual) progress through the beatmap (0..1).</param>
/// <param name="isIntro">If <c>true</c>, progress is (0..1) through the intro.</param>
protected abstract void UpdateProgress(double progress, bool isIntro);
protected virtual void UpdateObjects(IEnumerable<HitObject> objects) { }
[BackgroundDependencyLoader]
@ -96,7 +103,7 @@ namespace osu.Game.Screens.Play.HUD
if (objects == null)
return;
double currentTime = FrameStableClock.CurrentTime;
double currentTime = Math.Min(FrameStableClock.CurrentTime, LastHitTime);
bool isInIntro = currentTime < FirstHitTime;

View File

@ -0,0 +1,97 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osuTK;
namespace osu.Game.Screens.Play.HUD
{
public abstract partial class SongProgressBar : CompositeDrawable
{
/// <summary>
/// The current seek position of the audio, on a (0..1) range.
/// This is generally the seek target, which will eventually match the gameplay clock when it catches up.
/// </summary>
protected double AudioProgress => length == 0 ? 1 : AudioTime / length;
/// <summary>
/// The current (non-frame-stable) audio time.
/// </summary>
protected double AudioTime => Math.Clamp(GameplayClock.CurrentTime - StartTime, 0.0, length);
[Resolved]
protected IGameplayClock GameplayClock { get; private set; } = null!;
/// <summary>
/// Action which is invoked when a seek is requested, with the proposed millisecond value for the seek operation.
/// </summary>
public Action<double>? OnSeek { get; set; }
/// <summary>
/// Whether the progress bar should allow interaction, ie. to perform seek operations.
/// </summary>
public virtual bool Interactive { get; set; }
public double StartTime { get; set; }
public double EndTime { get; set; } = 1.0;
private double length => EndTime - StartTime;
private bool handleClick;
protected override bool OnMouseDown(MouseDownEvent e)
{
handleClick = true;
return base.OnMouseDown(e);
}
protected override bool OnClick(ClickEvent e)
{
if (handleClick)
handleMouseInput(e);
return true;
}
protected override void OnDrag(DragEvent e)
{
handleMouseInput(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
Vector2 posDiff = e.MouseDownPosition - e.MousePosition;
if (Math.Abs(posDiff.X) < Math.Abs(posDiff.Y))
{
handleClick = false;
return false;
}
handleMouseInput(e);
return true;
}
private void handleMouseInput(UIEvent e)
{
if (!Interactive)
return;
double relativeX = Math.Clamp(ToLocalSpace(e.ScreenSpaceMousePosition).X / DrawWidth, 0, 1);
onUserChange(StartTime + (EndTime - StartTime) * relativeX);
}
private ScheduledDelegate? scheduledSeek;
private void onUserChange(double value)
{
scheduledSeek?.Cancel();
scheduledSeek = Schedule(() => OnSeek?.Invoke(value));
}
}
}

View File

@ -28,7 +28,7 @@ namespace osu.Game.Screens.Play
private void userBeganPlaying(int userId, SpectatorState state)
{
if (userId == Score.UserID)
if (userId == Score?.UserID)
{
Schedule(() =>
{

View File

@ -5,10 +5,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Scoring;
@ -32,7 +33,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics
}
[BackgroundDependencyLoader]
private void load(ScorePerformanceCache performanceCache)
private void load(BeatmapDifficultyCache difficultyCache, CancellationToken? cancellationToken)
{
if (score.PP.HasValue)
{
@ -40,8 +41,19 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics
}
else
{
performanceCache.CalculatePerformanceAsync(score, cancellationTokenSource.Token)
.ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()?.Total)), cancellationTokenSource.Token);
Task.Run(async () =>
{
var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken ?? default).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;
var result = await performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken ?? default).ConfigureAwait(false);
Schedule(() => setPerformanceValue(result.Total));
}, cancellationToken ?? default);
}
}

View File

@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@ -45,6 +46,7 @@ namespace osu.Game.Screens.Ranking
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
[CanBeNull]
public readonly ScoreInfo Score;
protected ScorePanelList ScorePanelList { get; private set; }
@ -69,7 +71,7 @@ namespace osu.Game.Screens.Ranking
private Sample popInSample;
protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true)
protected ResultsScreen([CanBeNull] ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true)
{
Score = score;
this.allowRetry = allowRetry;
@ -275,6 +277,11 @@ namespace osu.Game.Screens.Ranking
if (base.OnExiting(e))
return true;
// This is a stop-gap safety against components holding references to gameplay after exiting the gameplay flow.
// Right now, HitEvents are only used up to the results screen. If this changes in the future we need to remove
// HitObject references from HitEvent.
Score?.HitEvents.Clear();
this.FadeOut(100);
return false;
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -45,12 +46,16 @@ namespace osu.Game.Screens.Ranking
{
base.LoadComplete();
Debug.Assert(Score != null);
if (ShowUserStatistics)
statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update);
}
protected override StatisticsPanel CreateStatisticsPanel()
{
Debug.Assert(Score != null);
if (ShowUserStatistics)
{
return new SoloStatisticsPanel(Score)
@ -64,6 +69,8 @@ namespace osu.Game.Screens.Ranking
protected override APIRequest? FetchScores(Action<IEnumerable<ScoreInfo>>? scoresCallback)
{
Debug.Assert(Score != null);
if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending)
return null;

View File

@ -39,9 +39,6 @@ namespace osu.Game.Screens.Ranking.Statistics
private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
[Resolved]
private ScorePerformanceCache performanceCache { get; set; }
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; }
@ -148,7 +145,7 @@ namespace osu.Game.Screens.Ranking.Statistics
spinner.Show();
new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache, performanceCache)
new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache)
.CalculateAsync(score, cancellationTokenSource.Token)
.ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely())));
}

View File

@ -6,34 +6,39 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications
namespace osu.Game.Tests
{
/// <summary>
/// An abstract client which receives notification-related events (chat/notifications).
/// </summary>
public abstract class NotificationsClient : PersistentEndpointClient
public class PollingChatClient : PersistentEndpointClient
{
public Action<Channel>? ChannelJoined;
public Action<Channel>? ChannelParted;
public Action<List<Message>>? NewMessages;
public Action? PresenceReceived;
public event Action<Channel>? ChannelJoined;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
protected readonly IAPIProvider API;
private readonly IAPIProvider api;
private long lastMessageId;
protected NotificationsClient(IAPIProvider api)
public PollingChatClient(IAPIProvider api)
{
API = api;
this.api = api;
}
public override Task ConnectAsync(CancellationToken cancellationToken)
{
API.Queue(CreateInitialFetchRequest(0));
Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
await api.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true);
await Task.Delay(1000, cancellationToken).ConfigureAwait(true);
}
}, cancellationToken);
return Task.CompletedTask;
}
@ -46,11 +51,11 @@ namespace osu.Game.Online.Notifications
if (updates?.Presence != null)
{
foreach (var channel in updates.Presence)
HandleChannelJoined(channel);
handleChannelJoined(channel);
//todo: handle left channels
HandleMessages(updates.Messages);
handleMessages(updates.Messages);
}
PresenceReceived?.Invoke();
@ -59,15 +64,13 @@ namespace osu.Game.Online.Notifications
return fetchReq;
}
protected void HandleChannelJoined(Channel channel)
private void handleChannelJoined(Channel channel)
{
channel.Joined.Value = true;
ChannelJoined?.Invoke(channel);
}
protected void HandleChannelParted(Channel channel) => ChannelParted?.Invoke(channel);
protected void HandleMessages(List<Message>? messages)
private void handleMessages(List<Message>? messages)
{
if (messages == null)
return;

View File

@ -1,35 +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.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Notifications;
namespace osu.Game.Tests
{
/// <summary>
/// A notifications client which polls for new messages every second.
/// </summary>
public class PollingNotificationsClient : NotificationsClient
{
public PollingNotificationsClient(IAPIProvider api)
: base(api)
{
}
public override Task ConnectAsync(CancellationToken cancellationToken)
{
Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
await API.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true);
await Task.Delay(1000, cancellationToken).ConfigureAwait(true);
}
}, cancellationToken);
return Task.CompletedTask;
}
}
}

View File

@ -1,24 +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.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Notifications;
namespace osu.Game.Tests
{
/// <summary>
/// A connector for <see cref="PollingNotificationsClient"/>s that poll for new messages.
/// </summary>
public class PollingNotificationsClientConnector : NotificationsClientConnector
{
public PollingNotificationsClientConnector(IAPIProvider api)
: base(api)
{
}
protected override Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken)
=> Task.FromResult((NotificationsClient)new PollingNotificationsClient(API));
}
}

View File

@ -0,0 +1,49 @@
// 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;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
namespace osu.Game.Tests
{
public class TestChatClientConnector : PersistentEndpointClientConnector, IChatClient
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted
{
add { }
remove { }
}
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
public void RequestPresence()
{
// don't really need to do anything special if we poll every second anyway.
}
public TestChatClientConnector(IAPIProvider api)
: base(api)
{
Start();
}
protected sealed override Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var client = new PollingChatClient(API);
client.ChannelJoined += c => ChannelJoined?.Invoke(c);
client.NewMessages += m => NewMessages?.Invoke(m);
client.PresenceReceived += () => PresenceReceived?.Invoke();
return Task.FromResult<PersistentEndpointClient>(client);
}
}
}

View File

@ -178,6 +178,7 @@ namespace osu.Game.Tests.Visual
LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, false);
API.Login("Rhythm Champion", "osu!");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
Dependencies.Get<SessionStatics>().SetValue(Static.MutedAudioNotificationShownOnce, true);

View File

@ -36,8 +36,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.121.1" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.116.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.127.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.127.0" />
<PackageReference Include="Sentry" Version="3.41.3" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.36.0" />

View File

@ -23,6 +23,6 @@
<RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.114.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.127.0" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@ -1 +1,14 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"iPhoneNotification2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"iPhoneNotification3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"iPhoneSettings2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"iPhoneSettings3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"iPhoneSpotlight2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"iPhoneSpotlight3x.png","scale":"3x"},{"size":"60x60","idiom":"iphone","filename":"iPhoneApp2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"iPhoneApp3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"iPadNotification1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"iPadNotification2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"iPadSettings1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"iPadSettings2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"iPadSpotlight1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"iPadSpotlight2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"iPadApp1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"iPadApp2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"iPadProApp2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"iOSAppStore.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
{
"images" : [
{
"filename" : "300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

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