Merge branch 'master' into fix-results-f-rank
@ -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>
|
||||
|
2
LICENCE
@ -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
|
||||
|
@ -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!
|
||||
|
||||
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
BIN
assets/lazer.png
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 321 KiB |
@ -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.
|
||||
|
@ -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" />
|
||||
|
618
osu.Android/Resources/drawable/ic_launcher_background.xml
Normal 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>
|
Before Width: | Height: | Size: 38 KiB |
24
osu.Android/Resources/drawable/monochrome.xml
Normal 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>
|
6
osu.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
BIN
osu.Android/Resources/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
osu.Android/Resources/mipmap-hdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
osu.Android/Resources/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
osu.Android/Resources/mipmap-mdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
osu.Android/Resources/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
osu.Android/Resources/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
osu.Android/Resources/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
osu.Android/Resources/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
osu.Android/Resources/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
osu.Android/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
osu.Desktop/lazer.ico
Executable file → Normal file
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 66 KiB |
@ -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>
|
||||
|
@ -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());
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat
|
||||
return false;
|
||||
};
|
||||
});
|
||||
|
||||
AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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()));
|
||||
|
||||
|
@ -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());
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
Schedule(() =>
|
||||
{
|
||||
API.Login("test", "test");
|
||||
dummyAPI.AuthenticateSecondFactor("abcdefgh");
|
||||
Child = commentsContainer = new CommentsContainer();
|
||||
});
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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]
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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),
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
24
osu.Game/Online/API/Requests/GetMeRequest.cs
Normal 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}";
|
||||
}
|
||||
}
|
@ -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")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
13
osu.Game/Online/API/Requests/Responses/APIMe.cs
Normal 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; }
|
||||
}
|
||||
}
|
30
osu.Game/Online/API/Requests/VerifySessionRequest.cs
Normal 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";
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
39
osu.Game/Online/Chat/IChatClient.cs
Normal 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;
|
||||
}
|
||||
}
|
173
osu.Game/Online/Chat/WebSocketChatClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
147
osu.Game/Overlays/Login/SecondFactorAuthForm.cs
Normal 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); });
|
||||
}
|
||||
}
|
||||
}
|
@ -99,6 +99,7 @@ namespace osu.Game.Overlays.Toolbar
|
||||
|
||||
switch (state.NewValue)
|
||||
{
|
||||
case APIState.RequiresSecondFactorAuth:
|
||||
case APIState.Connecting:
|
||||
TooltipText = ToolbarStrings.Connecting;
|
||||
spinner.Show();
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
@ -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();
|
||||
|