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

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

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

View File

@ -35,7 +35,7 @@
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<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.131.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>
@ -19,5 +20,6 @@
<file src="**.dll" target="lib\net45\"/>
<file src="**.config" target="lib\net45\"/>
<file src="**.json" target="lib\net45\"/>
<file src="icon.png" target=""/>
</files>
</package>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat
return false;
};
});
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

@ -466,7 +466,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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
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,26 +14,26 @@ 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>();
var req = new GetNotificationsRequest();
req.Success += bundle => tcs.SetResult(bundle.Endpoint);
req.Failure += ex => tcs.SetException(ex);
api.Queue(req);
string endpoint = await tcs.Task.ConfigureAwait(false);
// must use `PerformAsync()`, since we may not be fully online yet
// (see `APIState.RequiresSecondFactorAuth` - in this state queued requests will not execute).
await api.PerformAsync(req).ConfigureAwait(false);
string endpoint = req.Response!.Endpoint;
ClientWebSocket socket = new ClientWebSocket();
socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}");
@ -40,7 +41,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

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

View File

@ -102,7 +102,7 @@ namespace osu.Game
public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild;
public virtual EndpointConfiguration CreateEndpoints() =>
UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ExperimentalEndpointConfiguration();
UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version();
@ -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

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

View File

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

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();

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