diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c4ba6e5143..6ec071be2f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,7 +9,7 @@ ] }, "nvika": { - "version": "3.0.0", + "version": "4.0.0", "commands": [ "nvika" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8645d728e..f041f2e916 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,8 +82,18 @@ jobs: run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - name: Test - run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0 - shell: pwsh + run: > + dotnet test + osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll + osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll + osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll + osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll + osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll + osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll + Templates/**/*.Tests/bin/Debug/**/*.Tests.dll + --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + -- + NUnit.ConsoleOut=0 # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always @@ -114,10 +124,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET workloads - # since windows image 20241113.3.0, not specifying a version here - # installs the .NET 7 version of android workload for very unknown reasons. - # revisit once we upgrade to .NET 9, it's probably fixed there. - run: dotnet workload install android --version (dotnet --version) + run: dotnet workload install android - name: Compile run: dotnet build -c Debug osu.Android.slnf @@ -139,4 +146,4 @@ jobs: run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json - name: Build - run: dotnet build -c Debug osu.iOS + run: dotnet build -c Debug osu.iOS.slnf diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 4297a88e89..8461208a2e 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -115,7 +115,7 @@ jobs: steps: - name: Check permissions run: | - ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte) + ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders) for i in "${ALLOWED_USERS[@]}"; do if [[ "${{ github.actor }}" == "$i" ]]; then exit 0 diff --git a/.globalconfig b/.globalconfig deleted file mode 100644 index a4d4707f9b..0000000000 --- a/.globalconfig +++ /dev/null @@ -1,57 +0,0 @@ -# .NET Code Style -# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ - -# IDE0001: Simplify names -dotnet_diagnostic.IDE0001.severity = warning - -# IDE0002: Simplify member access -dotnet_diagnostic.IDE0002.severity = warning - -# IDE0003: Remove qualification -dotnet_diagnostic.IDE0003.severity = warning - -# IDE0004: Remove unnecessary cast -dotnet_diagnostic.IDE0004.severity = warning - -# IDE0005: Remove unnecessary imports -dotnet_diagnostic.IDE0005.severity = warning - -# IDE0034: Simplify default literal -dotnet_diagnostic.IDE0034.severity = warning - -# IDE0036: Sort modifiers -dotnet_diagnostic.IDE0036.severity = warning - -# IDE0040: Add accessibility modifier -dotnet_diagnostic.IDE0040.severity = warning - -# IDE0049: Use keyword for type name -dotnet_diagnostic.IDE0040.severity = warning - -# IDE0055: Fix formatting -dotnet_diagnostic.IDE0055.severity = warning - -# IDE0051: Private method is unused -dotnet_diagnostic.IDE0051.severity = silent - -# IDE0052: Private member is unused -dotnet_diagnostic.IDE0052.severity = silent - -# IDE0073: File header -dotnet_diagnostic.IDE0073.severity = warning - -# IDE0130: Namespace mismatch with folder -dotnet_diagnostic.IDE0130.severity = warning - -# IDE1006: Naming style -dotnet_diagnostic.IDE1006.severity = warning - -#Disable operator overloads requiring alternate named methods -dotnet_diagnostic.CA2225.severity = none - -# Banned APIs -dotnet_diagnostic.RS0030.severity = error - -# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. -# See: https://github.com/ppy/osu/pull/19677 -dotnet_diagnostic.OSUF001.severity = none diff --git a/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000000..4432459b86 --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 3c60b28765..08b79fc2c0 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -14,11 +14,10 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Gen M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks. M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. -M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. -M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. -M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. -M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead. +M:osuTK.MathHelper.Clamp(System.Int32,System.Int32,System.Int32)~System.Int32;Use Math.Clamp() instead. +M:osuTK.MathHelper.Clamp(System.Single,System.Single,System.Single)~System.Single;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead. +M:osuTK.MathHelper.Clamp(System.Double,System.Double,System.Double)~System.Double;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead. diff --git a/CodeAnalysis/osu.globalconfig b/CodeAnalysis/osu.globalconfig new file mode 100644 index 0000000000..8012c31eca --- /dev/null +++ b/CodeAnalysis/osu.globalconfig @@ -0,0 +1,112 @@ +# .NET Code Style +# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ +is_global = true + +# IDE0001: Simplify names +dotnet_diagnostic.IDE0001.severity = warning + +# IDE0002: Simplify member access +dotnet_diagnostic.IDE0002.severity = warning + +# IDE0003: Remove qualification +dotnet_diagnostic.IDE0003.severity = warning + +# IDE0004: Remove unnecessary cast +dotnet_diagnostic.IDE0004.severity = warning + +# IDE0005: Remove unnecessary imports +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0034: Simplify default literal +dotnet_diagnostic.IDE0034.severity = warning + +# IDE0036: Sort modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0040: Add accessibility modifier +dotnet_diagnostic.IDE0040.severity = warning + +# IDE0049: Use keyword for type name +dotnet_diagnostic.IDE0040.severity = warning + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# IDE0051: Private method is unused +dotnet_diagnostic.IDE0051.severity = silent + +# IDE0052: Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = warning + +# IDE0130: Namespace mismatch with folder +dotnet_diagnostic.IDE0130.severity = warning + +# IDE1006: Naming style +dotnet_diagnostic.IDE1006.severity = warning + +# CA1305: Specify IFormatProvider +# Too many noisy warnings for parsing/formatting numbers +dotnet_diagnostic.CA1305.severity = none + +# messagepack complains about "osu" not being title cased due to reserved words +dotnet_diagnostic.CS8981.severity = none + +# CA1507: Use nameof to express symbol names +# Flags serialization name attributes +dotnet_diagnostic.CA1507.severity = suggestion + +# CA1806: Do not ignore method results +# The usages for numeric parsing are explicitly optional +dotnet_diagnostic.CA1806.severity = suggestion + +# CA1822: Mark members as static +# Potential false positive around reflection/too much noise +dotnet_diagnostic.CA1822.severity = none + +# CA1826: Do not use Enumerable method on indexable collections +dotnet_diagnostic.CA1826.severity = suggestion + +# CA1859: Use concrete types when possible for improved performance +# Involves design considerations +dotnet_diagnostic.CA1859.severity = suggestion + +# CA1860: Avoid using 'Enumerable.Any()' extension method +dotnet_diagnostic.CA1860.severity = suggestion + +# CA1861: Avoid constant arrays as arguments +# Outdated with collection expressions +dotnet_diagnostic.CA1861.severity = suggestion + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = warning + +# CA2016: Forward the 'CancellationToken' parameter to methods +# Some overloads are having special handling for debugger +dotnet_diagnostic.CA2016.severity = suggestion + +# CA2021: Do not call Enumerable.Cast or Enumerable.OfType with incompatible types +# Causing a lot of false positives with generics +dotnet_diagnostic.CA2021.severity = none + +# CA2101: Specify marshaling for P/Invoke string arguments +# Reports warning for all non-UTF16 usages on DllImport; consider migrating to LibraryImport +dotnet_diagnostic.CA2101.severity = none + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = warning + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = suggestion + +# CA2242: Test for NaN correctly +dotnet_diagnostic.CA2242.severity = warning + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error + +# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. +# See: https://github.com/ppy/osu/pull/19677 +dotnet_diagnostic.OSUF001.severity = none diff --git a/CodeAnalysis/osu.ruleset b/CodeAnalysis/osu.ruleset deleted file mode 100644 index 6a99e230d1..0000000000 --- a/CodeAnalysis/osu.ruleset +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 5ba12b845b..3acb86ee0c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,9 +18,21 @@ + + - $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset + Default + Default + Recommended + Recommended + Recommended + Recommended + Default + Minimum + Recommended + Default + Default true diff --git a/README.md b/README.md index 6043497181..d87ca31f72 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu! If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below. -**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024. +**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation. ## Developing a custom ruleset diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index f77cda1533..86f73a37d4 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs index 9cd18d2d9f..0699f5d039 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs @@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.EmptyFreeform.Objects public Vector2 Position { get; set; } - public float X => Position.X; - public float Y => Position.Y; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(X, value); + } } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 47cabaddb1..51c0233942 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs index 0c22554e82..f938d26b26 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs @@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.Pippidon.Objects public Vector2 Position { get; set; } - public float X => Position.X; - public float Y => Position.Y; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(X, value); + } } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index a7d62291d0..ed4e8631ea 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 47cabaddb1..51c0233942 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs index 0a4fa84ce1..dd8337abee 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -9,7 +10,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.UI; -using osuTK; namespace osu.Game.Rulesets.Pippidon.Beatmaps { @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps }; } - private int getLane(HitObject hitObject) => (int)MathHelper.Clamp( + private int getLane(HitObject hitObject) => (int)Math.Clamp( (getUsablePosition(hitObject) - minPosition) / (maxPosition - minPosition) * PippidonPlayfield.LANE_COUNT, 0, PippidonPlayfield.LANE_COUNT - 1); private float getUsablePosition(HitObject h) => (h as IHasYPosition)?.Y ?? ((IHasXPosition)h).X; diff --git a/osu.Android.props b/osu.Android.props index 02898623a9..8e383a705c 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + + + %(RecursiveDir)%(Filename)%(Extension) + iOS\%(RecursiveDir)%(Filename)%(Extension) + diff --git a/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs new file mode 100644 index 0000000000..1dda2e314d --- /dev/null +++ b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Beatmaps +{ + public class BeatmapExtensionsTest + { + [Test] + public void TestLengthCalculations() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = 5_000 }, + new HitCircle { StartTime = 300_000 }, + new Spinner { StartTime = 280_000, Duration = 40_000 } + }, + Breaks = + { + new BreakPeriod(50_000, 75_000), + new BreakPeriod(100_000, 150_000), + } + }; + + Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000))); + Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000 + Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(240_000)); // 315_000 - (25_000 + 50_000) = 315_000 - 75_000 + } + + [Test] + public void TestDrainLengthCannotGoNegative() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = 5_000 }, + new HitCircle { StartTime = 300_000 }, + new Spinner { StartTime = 280_000, Duration = 40_000 } + }, + Breaks = + { + new BreakPeriod(0, 350_000), + } + }; + + Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000))); + Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000 + Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(0)); // break period encompasses entire beatmap + } + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index b5c299ed9d..916e1e757a 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -42,9 +42,9 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = Decoder.GetDecoder(stream); var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(6, working.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(6, working.Beatmap.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion); + Assert.AreEqual(6, working.Beatmap.BeatmapVersion); + Assert.That(working.Beatmap.BeatmapInfo.Ruleset.Name, Is.Not.EqualTo("null placeholder ruleset")); + Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); } } @@ -59,9 +59,8 @@ namespace osu.Game.Tests.Beatmaps.Formats ((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets; var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(4, working.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(4, working.Beatmap.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion); + Assert.AreEqual(4, working.Beatmap.BeatmapVersion); + Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime); } @@ -80,16 +79,16 @@ namespace osu.Game.Tests.Beatmaps.Formats var metadata = beatmap.Metadata; Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile); - Assert.AreEqual(0, beatmapInfo.AudioLeadIn); + Assert.AreEqual(0, beatmap.AudioLeadIn); Assert.AreEqual(164471, metadata.PreviewTime); - Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); + Assert.AreEqual(0.7f, beatmap.StackLeniency); Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.IsFalse(beatmapInfo.LetterboxInBreaks); - Assert.IsFalse(beatmapInfo.SpecialStyle); - Assert.IsFalse(beatmapInfo.WidescreenStoryboard); - Assert.IsFalse(beatmapInfo.SamplesMatchPlaybackRate); - Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); - Assert.AreEqual(0, beatmapInfo.CountdownOffset); + Assert.IsFalse(beatmap.LetterboxInBreaks); + Assert.IsFalse(beatmap.SpecialStyle); + Assert.IsFalse(beatmap.WidescreenStoryboard); + Assert.IsFalse(beatmap.SamplesMatchPlaybackRate); + Assert.AreEqual(CountdownType.None, beatmap.Countdown); + Assert.AreEqual(0, beatmap.CountdownOffset); } } @@ -101,7 +100,7 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) using (var stream = new LineBufferedReader(resStream)) { - var beatmapInfo = decoder.Decode(stream).BeatmapInfo; + var beatmap = decoder.Decode(stream); int[] expectedBookmarks = { @@ -109,13 +108,13 @@ namespace osu.Game.Tests.Beatmaps.Formats 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmapInfo.Bookmarks.Length); + Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmapInfo.Bookmarks[i]); - Assert.AreEqual(1.8, beatmapInfo.DistanceSpacing); - Assert.AreEqual(4, beatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmapInfo.GridSize); - Assert.AreEqual(2, beatmapInfo.TimelineZoom); + Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + Assert.AreEqual(1.8, beatmap.DistanceSpacing); + Assert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor); + Assert.AreEqual(4, beatmap.GridSize); + Assert.AreEqual(2, beatmap.TimelineZoom); } } @@ -404,6 +403,35 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestComboColourCountIsLimitedToEight() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("too-many-combo-colours.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var comboColors = decoder.Decode(stream).ComboColours; + + Debug.Assert(comboColors != null); + + Color4[] expectedColors = + { + new Color4(142, 199, 255, 255), + new Color4(255, 128, 128, 255), + new Color4(128, 255, 255, 255), + new Color4(128, 255, 128, 255), + new Color4(255, 187, 255, 255), + new Color4(255, 177, 140, 255), + new Color4(100, 100, 100, 255), + new Color4(142, 199, 255, 255), + }; + Assert.AreEqual(expectedColors.Length, comboColors.Count); + for (int i = 0; i < expectedColors.Length; i++) + Assert.AreEqual(expectedColors[i], comboColors[i]); + } + } + [Test] public void TestGetLastObjectTime() { @@ -993,15 +1021,15 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.Multiple(() => { - Assert.That(decoded.BeatmapInfo.AudioLeadIn, Is.EqualTo(0)); - Assert.That(decoded.BeatmapInfo.StackLeniency, Is.EqualTo(0.7f)); - Assert.That(decoded.BeatmapInfo.SpecialStyle, Is.False); - Assert.That(decoded.BeatmapInfo.LetterboxInBreaks, Is.False); - Assert.That(decoded.BeatmapInfo.WidescreenStoryboard, Is.False); - Assert.That(decoded.BeatmapInfo.EpilepsyWarning, Is.False); - Assert.That(decoded.BeatmapInfo.SamplesMatchPlaybackRate, Is.False); - Assert.That(decoded.BeatmapInfo.Countdown, Is.EqualTo(CountdownType.None)); - Assert.That(decoded.BeatmapInfo.CountdownOffset, Is.EqualTo(0)); + Assert.That(decoded.AudioLeadIn, Is.EqualTo(0)); + Assert.That(decoded.StackLeniency, Is.EqualTo(0.7f)); + Assert.That(decoded.SpecialStyle, Is.False); + Assert.That(decoded.LetterboxInBreaks, Is.False); + Assert.That(decoded.WidescreenStoryboard, Is.False); + Assert.That(decoded.EpilepsyWarning, Is.False); + Assert.That(decoded.SamplesMatchPlaybackRate, Is.False); + Assert.That(decoded.Countdown, Is.EqualTo(CountdownType.None)); + Assert.That(decoded.CountdownOffset, Is.EqualTo(0)); Assert.That(decoded.BeatmapInfo.Metadata.PreviewTime, Is.EqualTo(-1)); Assert.That(decoded.BeatmapInfo.Ruleset.OnlineID, Is.EqualTo(0)); }); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index c8a09786ec..caebf52026 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -28,6 +28,7 @@ using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; +using osuTK.Graphics; namespace osu.Game.Tests.Beatmaps.Formats { @@ -184,6 +185,32 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(5)); } + [Test] + public void TestOnlyEightComboColoursEncoded() + { + var beatmapSkin = new LegacyBeatmapSkin(new BeatmapInfo(), null) + { + Configuration = + { + CustomComboColours = + { + new Color4(1, 1, 1, 255), + new Color4(2, 2, 2, 255), + new Color4(3, 3, 3, 255), + new Color4(4, 4, 4, 255), + new Color4(5, 5, 5, 255), + new Color4(6, 6, 6, 255), + new Color4(7, 7, 7, 255), + new Color4(8, 8, 8, 255), + new Color4(9, 9, 9, 255), + } + } + }; + + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((new Beatmap(), beatmapSkin)), string.Empty); + Assert.That(decodedAfterEncode.skin.Configuration.CustomComboColours, Has.Count.EqualTo(8)); + } + private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) { // equal to null, no need to SequenceEqual @@ -212,6 +239,8 @@ namespace osu.Game.Tests.Beatmaps.Formats { var beatmap = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader); var beatmapSkin = new TestLegacySkin(beatmaps_resource_store, name); + stream.Seek(0, SeekOrigin.Begin); + beatmapSkin.Configuration = new LegacySkinDecoder().Decode(reader); return (convert(beatmap), beatmapSkin); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 713f2f3fb1..de07e2be01 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -155,10 +155,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); var beatmap = new TestBeatmap(ruleset) { - BeatmapInfo = - { - BeatmapVersion = beatmapVersion - } + BeatmapVersion = beatmapVersion }; var score = new Score @@ -633,14 +630,14 @@ namespace osu.Game.Tests.Beatmaps.Formats MD5Hash = md5Hash, Ruleset = new OsuRuleset().RulesetInfo, Difficulty = new BeatmapDifficulty(), - BeatmapVersion = beatmapVersion, }, - // needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die + // needs to have at least one object so that `StandardisedScoreMigrationTools` doesn't die // when trying to recompute total score. HitObjects = { new HitCircle() - } + }, + BeatmapVersion = beatmapVersion, }); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index 3764467047..c20cf7befd 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -51,14 +51,14 @@ namespace osu.Game.Tests.Beatmaps.Formats { var beatmap = decodeAsJson(normal); var beatmapInfo = beatmap.BeatmapInfo; - Assert.AreEqual(0, beatmapInfo.AudioLeadIn); - Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); - Assert.AreEqual(false, beatmapInfo.SpecialStyle); + Assert.AreEqual(0, beatmap.AudioLeadIn); + Assert.AreEqual(0.7f, beatmap.StackLeniency); + Assert.AreEqual(false, beatmap.SpecialStyle); Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.AreEqual(false, beatmapInfo.LetterboxInBreaks); - Assert.AreEqual(false, beatmapInfo.WidescreenStoryboard); - Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); - Assert.AreEqual(0, beatmapInfo.CountdownOffset); + Assert.AreEqual(false, beatmap.LetterboxInBreaks); + Assert.AreEqual(false, beatmap.WidescreenStoryboard); + Assert.AreEqual(CountdownType.None, beatmap.Countdown); + Assert.AreEqual(0, beatmap.CountdownOffset); } [Test] @@ -73,13 +73,13 @@ namespace osu.Game.Tests.Beatmaps.Formats 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmapInfo.Bookmarks.Length); + Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmapInfo.Bookmarks[i]); - Assert.AreEqual(1.8, beatmapInfo.DistanceSpacing); + Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + Assert.AreEqual(1.8, beatmap.DistanceSpacing); Assert.AreEqual(4, beatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmapInfo.GridSize); - Assert.AreEqual(2, beatmapInfo.TimelineZoom); + Assert.AreEqual(4, beatmap.GridSize); + Assert.AreEqual(2, beatmap.TimelineZoom); } [Test] diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs index 8a95d26782..cf498c7856 100644 --- a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO.Archives; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; using MemoryStream = System.IO.MemoryStream; @@ -50,6 +51,29 @@ namespace osu.Game.Tests.Beatmaps.IO AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001)); } + [Test] + public void TestFractionalObjectCoordinatesRounded() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"fractional-coordinates.olz")); + AddAssert("hit object has fractional position", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(383.99997).Within(0.00001)); + + // Ensure exporter legacy conversion is correct + AddStep("export", () => + { + outStream = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("hit object is snapped", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(384).Within(0.00001)); + } + [Test] public void TestExportStability() { diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index c7cf3fe956..ee2733ad91 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -112,5 +112,20 @@ namespace osu.Game.Tests.Beatmaps } }); } + + [Test] + public void TestRepeatsGeneratedEvenForZeroLengthSlider() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, 0, 2).ToArray(); + + Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); + Assert.That(events[0].Time, Is.EqualTo(start_time)); + + Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Repeat)); + Assert.That(events[1].Time, Is.EqualTo(span_duration)); + + Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tail)); + Assert.That(events[3].Time, Is.EqualTo(span_duration * 2)); + } } } diff --git a/osu.Game.Tests/CodeAnalysis.tests.globalconfig b/osu.Game.Tests/CodeAnalysis.tests.globalconfig new file mode 100644 index 0000000000..3f039736ec --- /dev/null +++ b/osu.Game.Tests/CodeAnalysis.tests.globalconfig @@ -0,0 +1,7 @@ +# Higher global_level has higher priority, the default global_level +# is 100 for root .globalconfig and 0 for others +# https://learn.microsoft.com/dotnet/fundamentals/code-analysis/configuration-files#precedence +is_global = true +global_level = 101 + +dotnet_diagnostic.CA2007.severity = none diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index c40624a3a0..bae8e7c76a 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -62,12 +62,11 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => - { - Add(new TestBackgroundDataStoreProcessor()); - }); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("wait for difficulties repopulated", () => + AddAssert("Difficulties repopulated", () => { return Realm.Run(r => { @@ -101,13 +100,10 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => - { - Add(new TestBackgroundDataStoreProcessor()); - }); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); AddWaitStep("wait some", 500); - AddAssert("Difficulty still not populated", () => { return Realm.Run(r => @@ -118,8 +114,9 @@ namespace osu.Game.Tests.Database }); AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("wait for difficulties repopulated", () => + AddAssert("Difficulties repopulated", () => { return Realm.Run(r => { @@ -151,9 +148,11 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor())); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION)); + AddAssert("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION)); AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False); } @@ -183,7 +182,7 @@ namespace osu.Game.Tests.Database AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True); + AddAssert("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True); AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(scoreVersion)); } diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index e5be4d665b..df83bc9b7b 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Database Assert.That(lastChanges?.ModifiedIndices, Is.Empty); Assert.That(lastChanges?.NewModifiedIndices, Is.Empty); - realm.Write(r => r.All().First().Beatmaps.First().CountdownOffset = 5); + realm.Write(r => r.All().First().Beatmaps.First().EditorTimestamp = 5); realm.Run(r => r.Refresh()); Assert.That(collectionChanges, Is.EqualTo(1)); diff --git a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs index bbcf6aac2c..c625346645 100644 --- a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs +++ b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs @@ -539,5 +539,85 @@ namespace osu.Game.Tests.Editing Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(5000 - OsuHitObject.PREEMPT_MAX)); }); } + + [Test] + public void TestPuttingObjectBetweenBreakEndAndAnotherObjectForcesNewCombo() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + Difficulty = + { + ApproachRate = 10, + }, + HitObjects = + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 4500 }, + new HitCircle { StartTime = 5000, NewCombo = true }, + }, + Breaks = + { + new BreakPeriod(2000, 4000), + } + }); + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True); + Assert.That(((HitCircle)beatmap.HitObjects[2]).NewCombo, Is.True); + + Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2)); + Assert.That(((HitCircle)beatmap.HitObjects[2]).ComboIndex, Is.EqualTo(3)); + }); + } + + [Test] + public void TestAutomaticallyInsertedBreakForcesNewCombo() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + Difficulty = + { + ApproachRate = 10, + }, + HitObjects = + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 5000 }, + }, + }); + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True); + + Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2)); + }); + } } } diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 0f8583253b..408db39d54 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -12,6 +12,7 @@ using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -67,17 +68,7 @@ namespace osu.Game.Tests.Editing { AddStep($"set slider multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier); - assertSnapDistance(100 * multiplier, null, true); - } - - [TestCase(1)] - [TestCase(2)] - public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) - { - assertSnapDistance(100, new Slider - { - SliderVelocityMultiplier = multiplier - }, false); + assertSnapDistance(100 * multiplier); } [TestCase(1)] @@ -87,7 +78,7 @@ namespace osu.Game.Tests.Editing assertSnapDistance(100 * multiplier, new Slider { SliderVelocityMultiplier = multiplier - }, true); + }); } [TestCase(1)] @@ -96,7 +87,7 @@ namespace osu.Game.Tests.Editing { AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor); - assertSnapDistance(100f / divisor, null, true); + assertSnapDistance(100f / divisor); } /// @@ -114,9 +105,8 @@ namespace osu.Game.Tests.Editing }; AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject)); - assertSnapDistance(base_distance * slider_velocity, referenceObject, true); + assertSnapDistance(base_distance * slider_velocity, referenceObject); assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); - assertSnappedDuration(base_distance * slider_velocity + 10, 1000, referenceObject); assertDistanceToDuration(base_distance * slider_velocity, 1000, referenceObject); assertDurationToDistance(1000, base_distance * slider_velocity, referenceObject); @@ -164,39 +154,6 @@ namespace osu.Game.Tests.Editing assertDistanceToDuration(400, 1000); } - [Test] - public void TestGetSnappedDurationFromDistance() - { - assertSnappedDuration(0, 0); - assertSnappedDuration(50, 1000); - assertSnappedDuration(100, 1000); - assertSnappedDuration(150, 2000); - assertSnappedDuration(200, 2000); - assertSnappedDuration(250, 3000); - - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); - - assertSnappedDuration(0, 0); - assertSnappedDuration(50, 0); - assertSnappedDuration(100, 1000); - assertSnappedDuration(150, 1000); - assertSnappedDuration(200, 1000); - assertSnappedDuration(250, 1000); - - AddStep("set beat length = 500", () => - { - composer.EditorBeatmap.ControlPointInfo.Clear(); - composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); - }); - - assertSnappedDuration(50, 0); - assertSnappedDuration(100, 500); - assertSnappedDuration(150, 500); - assertSnappedDuration(200, 500); - assertSnappedDuration(250, 500); - assertSnappedDuration(400, 1000); - } - [Test] public void GetSnappedDistanceFromDistance() { @@ -289,20 +246,17 @@ namespace osu.Game.Tests.Editing AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); } - private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) - => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + private void assertSnapDistance(float expectedDistance, IHasSliderVelocity? hasSliderVelocity = null) + => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistance(hasSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(duration, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); - - private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(distance, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(distance, referenceObject?.GetEndTime() ?? 0, referenceObject as IHasSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index decb0a31ac..2964ca9396 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -6,6 +6,7 @@ using System.Linq; using Moq; using NUnit.Framework; using osu.Framework.Localisation; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -342,6 +343,40 @@ namespace osu.Game.Tests.Mods Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); } + [Test] + public void TestRoomModValidity() + { + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); + // For now, adaptive speed isn't allowed in multiplayer because it's a per-user rate adjustment. + Assert.IsFalse(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + } + + [Test] + public void TestRoomFreeModValidity() + { + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); + // For now, all rate adjustment mods aren't allowed as free mods in multiplayer. + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + } + public abstract class CustomMod1 : Mod, IModCompatibilitySpecification { } diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 559db16751..8364e58bdc 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -6,9 +6,9 @@ using Humanizer; using NUnit.Framework; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.NonVisual.Multiplayer @@ -16,6 +16,13 @@ namespace osu.Game.Tests.NonVisual.Multiplayer [HeadlessTest] public partial class StatefulMultiplayerClientTest : MultiplayerTestScene { + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + } + [Test] public void TestUserAddedOnJoin() { @@ -72,10 +79,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("create room initially in gameplay", () => { - var newRoom = new Room(); - newRoom.CopyFrom(SelectedRoom.Value!); - - newRoom.RoomID = null; MultiplayerClient.RoomSetupAction = room => { room.State = MultiplayerRoomState.Playing; @@ -86,13 +89,32 @@ namespace osu.Game.Tests.NonVisual.Multiplayer }); }; - RoomManager.CreateRoom(newRoom); + MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false); }); AddUntilStep("wait for room join", () => RoomJoined); checkPlayingUserCount(1); } + [Test] + public void TestJoinRoomWithManyUsers() + { + AddStep("leave room", () => MultiplayerClient.LeaveRoom()); + AddUntilStep("wait for room part", () => !RoomJoined); + + AddStep("create room with many users", () => + { + MultiplayerClient.RoomSetupAction = room => + { + room.Users.AddRange(Enumerable.Range(PLAYER_1_ID, 100).Select(id => new MultiplayerRoomUser(id))); + }; + + MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false); + }); + + AddUntilStep("wait for room join", () => RoomJoined); + } + private void checkPlayingUserCount(int expectedCount) => AddAssert($"{"user".ToQuantity(expectedCount)} playing", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count == expectedCount); diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs index 03dc91b5d4..18ac5b4964 100644 --- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs @@ -36,6 +36,10 @@ namespace osu.Game.Tests.NonVisual.Ranking .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) .ToList(); + // Add some red herrings + events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null)); + events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null)); + HitEventExtensions.UnstableRateCalculationResult result = null; for (int i = 0; i < events.Count; i++) @@ -57,6 +61,10 @@ namespace osu.Game.Tests.NonVisual.Ranking .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) .ToList(); + // Add some red herrings + events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null)); + events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null)); + HitEventExtensions.UnstableRateCalculationResult result = null; for (int i = 0; i < events.Count; i++) diff --git a/osu.Game.Tests/Online/TestSceneMetadataClient.cs b/osu.Game.Tests/Online/TestSceneMetadataClient.cs new file mode 100644 index 0000000000..04e1d91edf --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneMetadataClient.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Tests.Visual; +using osu.Game.Tests.Visual.Metadata; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + [HeadlessTest] + public partial class TestSceneMetadataClient : OsuTestScene + { + private TestMetadataClient client = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = client = new TestMetadataClient(); + }); + + [Test] + public void TestWatchingMultipleTimesInvokesServerMethodsOnce() + { + int countBegin = 0; + int countEnd = 0; + + IDisposable token1 = null!; + IDisposable token2 = null!; + + AddStep("setup", () => + { + client.OnBeginWatchingUserPresence += () => countBegin++; + client.OnEndWatchingUserPresence += () => countEnd++; + }); + + AddStep("begin watching presence (1)", () => token1 = client.BeginWatchingUserPresence()); + AddAssert("server method invoked once", () => countBegin, () => Is.EqualTo(1)); + + AddStep("begin watching presence (2)", () => token2 = client.BeginWatchingUserPresence()); + AddAssert("server method not invoked a second time", () => countBegin, () => Is.EqualTo(1)); + + AddStep("end watching presence (1)", () => token1.Dispose()); + AddAssert("server method not invoked", () => countEnd, () => Is.EqualTo(0)); + + AddStep("end watching presence (2)", () => token2.Dispose()); + AddAssert("server method invoked once", () => countEnd, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs new file mode 100644 index 0000000000..d463610034 --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public partial class TestSceneOnlinePlaySubScreenStack : OnlinePlayTestScene + { + private ScreenStack stack = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = stack = new OnlinePlaySubScreenStack + { + RelativeSizeAxes = Axes.Both + }; + }); + + [Test] + public void TestBindablesDisabledWhenRequested() + { + AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False); + + AddStep("push screen that disables bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(true))); + AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True); + + AddStep("push screen that does not disable bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(false))); + AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False); + + AddStep("exit one screen", () => stack.Exit()); + AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True); + } + + [Test] + public void TestModsResetWhenExitToLounge() + { + AddStep("push lounge", () => stack.Push(new PlaylistsLoungeSubScreen())); + + AddStep("push screen with mod", () => stack.Push(new ScreenWithMod(new OsuModDoubleTime()))); + AddUntilStep("wait for screen to load", () => ((OsuScreen)stack.CurrentScreen).IsLoaded); + AddAssert("mod set", () => SelectedMods.Value.Count, () => Is.GreaterThan(0)); + + AddStep("exit to lounge", () => stack.Exit()); + AddAssert("mods reset", () => SelectedMods.Value.Count, () => Is.Zero); + } + + private partial class ScreenWithExternalBindableDisablement : OsuScreen + { + public override bool DisallowExternalBeatmapRulesetChanges { get; } + + public ScreenWithExternalBindableDisablement(bool disableBindables) + { + DisallowExternalBeatmapRulesetChanges = disableBindables; + } + } + + private partial class ScreenWithMod : OsuScreen + { + private readonly Mod mod; + + public ScreenWithMod(Mod mod) + { + this.mod = mod; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Mods.Value = [mod]; + } + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz new file mode 100644 index 0000000000..5c5af368c8 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk new file mode 100644 index 0000000000..23322e7373 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk new file mode 100644 index 0000000000..74abef25ca Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk new file mode 100644 index 0000000000..8ed25fa8f4 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk differ diff --git a/osu.Game.Tests/Resources/too-many-combo-colours.osu b/osu.Game.Tests/Resources/too-many-combo-colours.osu new file mode 100644 index 0000000000..477e362a6d --- /dev/null +++ b/osu.Game.Tests/Resources/too-many-combo-colours.osu @@ -0,0 +1,73 @@ +osu file format v14 + +[General] +AudioFilename: 03. Renatus - Soleily 192kbps.mp3 +AudioLeadIn: 0 +PreviewTime: 164471 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0.7 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 11505,22054,32604,43153,53703,64252,74802,85351,95901,106450,116999,119637,130186,140735,151285,161834,164471,175020,185570,196119,206669,209306 +DistanceSpacing: 1.8 +BeatDivisor: 4 +GridSize: 4 +TimelineZoom: 2 + +[Metadata] +Title:Renatus +TitleUnicode:Renatus +Artist:Soleily +ArtistUnicode:Soleily +Creator:Gamu +Version:Insane +Source: +Tags:MBC7 Unisphere 地球ヤバイEP Chikyu Yabai +BeatmapID:557821 +BeatmapSetID:241526 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9 +SliderMultiplier:1.8 +SliderTickRate:2 + +[Events] +//Background and Video events +0,0,"machinetop_background.jpg",0,0 +//Break Periods +2,122474,140135 +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +956,329.67032967033,4,2,0,60,1,0 + + +[Colours] +Combo1:142,199,255 +Combo2:255,128,128 +Combo3:128,255,255 +Combo4:128,255,128 +Combo5:255,187,255 +Combo6:255,177,140 +Combo7:100,100,100 +Combo8:142,199,255 +Combo9:255,128,128 +Combo10:128,255,255 +Combo11:128,255,128 +Combo12:255,187,255 +Combo13:255,177,140 +Combo14:100,100,100 + +[HitObjects] +192,168,956,6,0,P|184:128|200:80,1,90,4|0,1:2|0:0,0:0:0:0: diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 7372557161..5b343c80c5 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -68,7 +68,13 @@ namespace osu.Game.Tests.Skins // Covers legacy rank display "Archives/modified-classic-20230809.osk", // Covers legacy key counter - "Archives/modified-classic-20240724.osk" + "Archives/modified-classic-20240724.osk", + // Covers skinnable mod display + "Archives/modified-default-20241207.osk", + // Covers skinnable spectator list + "Archives/modified-argon-20250116.osk", + // Covers player team flag + "Archives/modified-argon-20250214.osk", }; /// diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index 54a722cee0..7b22ff1d6a 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -131,21 +131,6 @@ namespace osu.Game.Tests.Visual.Background assertNoBackgrounds(); } - [Test] - public void TestDelayedConnectivity() - { - registerBackgroundsResponse(DateTimeOffset.Now.AddDays(30)); - setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); - AddStep("go offline", () => dummyAPI.SetState(APIState.Offline)); - - createLoader(); - assertNoBackgrounds(); - - AddStep("go online", () => dummyAPI.SetState(APIState.Online)); - - assertAnyBackground(); - } - private void registerBackgroundsResponse(DateTimeOffset endDate) => AddStep("setup request handler", () => { @@ -185,7 +170,8 @@ namespace osu.Game.Tests.Visual.Background { previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault(); background = backgroundLoader.LoadNextBackground(); - LoadComponentAsync(background, bg => backgroundContainer.Child = bg); + if (background != null) + LoadComponentAsync(background, bg => backgroundContainer.Child = bg); }); AddUntilStep("background loaded", () => background.IsLoaded); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index d8be57382f..eeaa68e2ee 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Linq; using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,6 +16,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.States; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -31,6 +33,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; @@ -45,21 +48,22 @@ namespace osu.Game.Tests.Visual.Background private LoadBlockingTestPlayer player; private BeatmapManager manager; private RulesetStore rulesets; + private UpdateCounter storyboardUpdateCounter; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); manager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - Add(detachedBeatmapStore); + Add(beatmapStore); Beatmap.SetDefault(); } @@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); } + [Test] + public void TestStoryboardUpdatesWhenDimmed() + { + performFullSetup(); + createFakeStoryboard(); + + AddStep("Enable fully dimmed storyboard", () => + { + player.StoryboardReplacesBackground.Value = true; + player.StoryboardEnabled.Value = true; + player.DimmableStoryboard.IgnoreUserSettings.Value = false; + songSelect.DimLevel.Value = 1f; + }); + + AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); + + AddWaitStep("wait some", 20); + + AddUntilStep("Storyboard is always present", () => player.ChildrenOfType().Single().AlwaysPresent, () => Is.True); + AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100)); + } + [Test] public void TestStoryboardIgnoreUserSettings() { @@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background { player.StoryboardEnabled.Value = false; player.StoryboardReplacesBackground.Value = false; - player.DimmableStoryboard.Add(new OsuSpriteText + player.DimmableStoryboard.AddRange(new Drawable[] { - Size = new Vector2(500, 50), - Alpha = 1, - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "THIS IS A STORYBOARD", - Font = new FontUsage(size: 50) + storyboardUpdateCounter = new UpdateCounter(), + new OsuSpriteText + { + Size = new Vector2(500, 50), + Alpha = 1, + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "THIS IS A STORYBOARD", + Font = new FontUsage(size: 50) + } }); }); @@ -353,7 +383,7 @@ namespace osu.Game.Tests.Visual.Background /// /// Make sure every time a screen gets pushed, the background doesn't get replaced /// - /// Whether or not the original background (The one created in DummySongSelect) is still the current background + /// Whether the original background (The one created in DummySongSelect) is still the current background public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true; } @@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard; - // Whether or not the player should be allowed to load. + // Whether the player should be allowed to load. public bool BlockLoad; public Bindable StoryboardEnabled; @@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background } } + private partial class UpdateCounter : Drawable + { + public double StoryboardContentLastUpdated; + + protected override void Update() + { + base.Update(); + StoryboardContentLastUpdated = Time.Current; + } + } + private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground { public Color4 CurrentColour => Content.Colour; diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs index 11fa6ed92d..39de2b7bc9 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Game.Beatmaps; +using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables; using osu.Game.Online.API.Requests.Responses; using osuTK; @@ -15,16 +13,18 @@ namespace osu.Game.Tests.Visual.Beatmaps { public partial class TestSceneDifficultySpectrumDisplay : OsuTestScene { - private DifficultySpectrumDisplay display; + private DifficultySpectrumDisplay display = null!; - private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet + [SetUpSteps] + public void SetUpSteps() { - Beatmaps = difficulties.Select(difficulty => new APIBeatmap + AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay { - RulesetID = difficulty.rulesetId, - StarRating = difficulty.stars - }).ToArray() - }; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(3) + }); + } [Test] public void TestSingleRuleset() @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 0, stars: 3.2), (rulesetId: 0, stars: 5.6)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] @@ -47,7 +47,7 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 1, stars: 4.3), (rulesetId: 0, stars: 5.6)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] @@ -61,52 +61,30 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 0, stars: 5.6), (rulesetId: 15, stars: 7.8)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] public void TestMaximumUncollapsed() { var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 12).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray()); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] public void TestMinimumCollapsed() { var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 13).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray()); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } - [Test] - public void TestAdjustableDotSize() + private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet { - var beatmapSet = createBeatmapSetWith( - (rulesetId: 0, stars: 2.0), - (rulesetId: 3, stars: 2.3), - (rulesetId: 0, stars: 3.2), - (rulesetId: 1, stars: 4.3), - (rulesetId: 0, stars: 5.6)); - - createDisplay(beatmapSet); - - AddStep("change dot dimensions", () => + Beatmaps = difficulties.Select(difficulty => new APIBeatmap { - display.DotSize = new Vector2(8, 12); - display.DotSpacing = 2; - }); - AddStep("change dot dimensions back", () => - { - display.DotSize = new Vector2(4, 8); - display.DotSpacing = 1; - }); - } - - private void createDisplay(IBeatmapSetInfo beatmapSetInfo) => AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay(beatmapSetInfo) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(3) - }); + RulesetID = difficulty.rulesetId, + StarRating = difficulty.stars + }).ToArray() + }; } } diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 0f2f716a07..60675018e9 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -376,6 +376,6 @@ namespace osu.Game.Tests.Visual.Collections private void assertCollectionName(int index, string name) => AddUntilStep($"item {index + 1} has correct name", - () => dialog.ChildrenOfType().Single().OrderedItems.ElementAt(index).ChildrenOfType().First().Text == name); + () => dialog.ChildrenOfType().Single().OrderedItems.ElementAtOrDefault(index)?.ChildrenOfType().First().Text == name); } } diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs new file mode 100644 index 0000000000..2fe2326508 --- /dev/null +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Components +{ + public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene + { + private ChannelManager channelManager = null!; + private NotificationOverlay notificationOverlay = null!; + private ChatOverlay chatOverlay = null!; + private TestMetadataClient metadataClient = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(ChannelManager), channelManager = new ChannelManager(API)), + (typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()), + (typeof(ChatOverlay), chatOverlay = new ChatOverlay()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()), + ], + Children = new Drawable[] + { + channelManager, + notificationOverlay, + chatOverlay, + metadataClient, + new FriendPresenceNotifier() + } + }; + + for (int i = 1; i <= 100; i++) + ((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } }); + }); + + [Test] + public void TestNotifications() + { + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null)); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + + [Test] + public void TestSingleUserNotificationOpensChat() + { + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username)); + } + + [Test] + public void TestMultipleUserNotificationDoesNotOpenChat() + { + AddStep("bring friends 1 & 2 online", () => + { + metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestNonFriendsDoNotNotify() + { + AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online })); + AddWaitStep("wait for possible notification", 10); + AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + + [Test] + public void TestPostManyDebounced() + { + AddStep("bring friends 1-10 online", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("bring friends 1-10 offline", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.FriendPresenceUpdated(i, null); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs index 0742ed5eb9..c974a852f3 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -6,6 +6,8 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Metadata; @@ -13,9 +15,11 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.SelectV2.Leaderboards; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.Metadata; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -57,6 +61,39 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddStep("push screen", () => LoadScreen(new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); } + [Test] + public void TestUseTheseModsUnavailableIfNoFreeMods() + { + var room = new Room + { + RoomID = 1234, + Name = "Daily Challenge: June 4, 2024", + Playlist = + [ + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [] + } + ], + EndDate = DateTimeOffset.Now.AddHours(12), + Category = RoomCategory.DailyChallenge + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; + AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + AddUntilStep("wait for pushed", () => screen.IsCurrentScreen()); + AddStep("force transforms to finish", () => FinishTransforms(true)); + AddStep("right click second score", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Right); + }); + AddAssert("use these mods not present", + () => this.ChildrenOfType().All(m => m.Items.All(item => item.Text.Value != "Use these mods"))); + } + [Test] public void TestNotifications() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs new file mode 100644 index 0000000000..f83d424d56 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Screens.Edit.Submission; +using osu.Game.Screens.Footer; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneBeatmapSubmissionOverlay : OsuTestScene + { + private ScreenFooter footer = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("add overlay", () => + { + var receptor = new ScreenFooter.BackReceptor(); + footer = new ScreenFooter(receptor); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new[] + { + (typeof(ScreenFooter), (object)footer), + (typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()), + }, + Children = new Drawable[] + { + receptor, + new BeatmapSubmissionOverlay + { + State = { Value = Visibility.Visible, }, + }, + footer, + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs index 4dd27a7b6e..2858650e3c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown off", () => designSection.EnableCountdown.Current.Value = false); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.None); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.None); AddUntilStep("other controls hidden", () => !designSection.CountdownSettings.IsPresent); } @@ -65,12 +65,12 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.Normal); AddUntilStep("other controls shown", () => designSection.CountdownSettings.IsPresent); AddStep("change countdown speed", () => designSection.CountdownSpeed.Current.Value = CountdownType.DoubleSpeed); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.DoubleSpeed); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.DoubleSpeed); AddUntilStep("other controls still shown", () => designSection.CountdownSettings.IsPresent); } @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.Normal); checkOffsetAfter("1", 1); checkOffsetAfter(string.Empty, 0); @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("commit text", () => InputManager.Key(Key.Enter)); AddAssert($"displayed value is {expectedFinalValue}", () => designSection.CountdownOffset.Current.Value == expectedFinalValue.ToString(CultureInfo.InvariantCulture)); - AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.BeatmapInfo.CountdownOffset == expectedFinalValue); + AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.CountdownOffset == expectedFinalValue); } private partial class TestDesignSection : DesignSection diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index c1a788cd22..fb57422e66 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; @@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.Editing public new int MaxIntervals => base.MaxIntervals; public TestDistanceSnapGrid(double? endTime = null) - : base(new HitObject(), grid_position, 0, endTime) + : base(grid_position, 0, endTime) { } @@ -181,7 +181,7 @@ namespace osu.Game.Tests.Visual.Editing } } - public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition) + public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition, double? fixedTime = null) => (Vector2.Zero, 0); } @@ -191,15 +191,13 @@ namespace osu.Game.Tests.Visual.Editing Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance; + public float GetBeatSnapDistance(IHasSliderVelocity withVelocity = null) => beat_snap_distance; - public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration; + public float DurationToDistance(double duration, double timingReference, IHasSliderVelocity withVelocity = null) => (float)duration; - public double DistanceToDuration(HitObject referenceObject, float distance) => distance; + public double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity withVelocity = null) => distance; - public double FindSnappedDuration(HitObject referenceObject, float distance) => 0; - - public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0; + public float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity withVelocity = null) => 0; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index db87987815..2758954907 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -4,11 +4,13 @@ using System; using System.IO; using System.Linq; +using System.Text; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -19,6 +21,7 @@ using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; @@ -27,6 +30,7 @@ using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Setup; +using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; @@ -90,6 +94,8 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.AsNonNull().ID)?.Value.DeletePending == true); + + AddUntilStep("wait for default beatmap", () => Editor.Beatmap.Value is DummyWorkingBeatmap); } [Test] @@ -98,44 +104,15 @@ namespace osu.Game.Tests.Visual.Editing AddStep("enter compose mode", () => InputManager.Key(Key.F1)); AddUntilStep("wait for timeline load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddStep("enter setup mode", () => InputManager.Key(Key.F4)); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual); - AddAssert("switch track to real track", () => - { - var setup = Editor.ChildrenOfType().First(); - - string temp = TestResources.GetTestBeatmapForImport(); - - string extractedFolder = $"{temp}_extracted"; - Directory.CreateDirectory(extractedFolder); - - try - { - using (var zip = ZipArchive.Open(temp)) - zip.WriteToDirectory(extractedFolder); - - bool success = setup.ChildrenOfType().First().ChangeAudioTrack(new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3"))); - - // ensure audio file is copied to beatmap as "audio.mp3" rather than original filename. - Assert.That(Beatmap.Value.Metadata.AudioFile == "audio.mp3"); - - return success; - } - finally - { - File.Delete(temp); - Directory.Delete(extractedFolder, true); - } - }); + AddAssert("switch track to real track", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000); AddStep("test play", () => Editor.TestGameplay()); - AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); - AddStep("confirm save", () => InputManager.Key(Key.Number1)); - AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen()); AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual); @@ -154,6 +131,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect point", () => EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true })); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle @@ -195,11 +173,18 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != firstDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has effect points", () => + { + var effectPoint = EditorBeatmap.ControlPointInfo.EffectPoints.Single(); + return effectPoint.Time == 500 && effectPoint.KiaiMode && effectPoint.ScrollSpeedBindable.IsDefault; + }); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); @@ -219,6 +204,117 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestCreateNewDifficultyWithScrollSpeed_SameRuleset() + { + string previousDifficultyName = null!; + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); + AddStep("save beatmap", () => Editor.Save()); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != previousDifficultyName; + }); + + ensureEditorLoaded(); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); + AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect points", () => + { + EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); + EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.1 }); + EditorBeatmap.ControlPointInfo.Add(750, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.15 }); + EditorBeatmap.ControlPointInfo.Add(1000, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.2 }); + EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); + }); + + AddStep("save beatmap", () => Editor.Save()); + + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); + AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction()); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != previousDifficultyName; + }); + + ensureEditorLoaded(); + + AddAssert("created difficulty has timing point", () => + { + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); + return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; + }); + + AddAssert("created difficulty has effect points", () => + { + return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] + { + new EffectControlPoint { Time = 250, KiaiMode = false, ScrollSpeed = 0.05 }, + new EffectControlPoint { Time = 500, KiaiMode = true, ScrollSpeed = 0.1 }, + new EffectControlPoint { Time = 750, KiaiMode = true, ScrollSpeed = 0.15 }, + new EffectControlPoint { Time = 1000, KiaiMode = false, ScrollSpeed = 0.2 }, + new EffectControlPoint { Time = 1500, KiaiMode = false, ScrollSpeed = 0.3 }, + }); + }); + } + + [Test] + public void TestCreateNewDifficultyWithScrollSpeed_DifferentRuleset() + { + string firstDifficultyName = Guid.NewGuid().ToString(); + + AddStep("save beatmap", () => Editor.Save()); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); + AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect points", () => + { + EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); + EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.1 }); + EditorBeatmap.ControlPointInfo.Add(750, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.15 }); + EditorBeatmap.ControlPointInfo.Add(1000, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.2 }); + EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); + }); + + AddStep("save beatmap", () => Editor.Save()); + + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo)); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != firstDifficultyName; + }); + + ensureEditorLoaded(); + + AddAssert("created difficulty has timing point", () => + { + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); + return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; + }); + + AddAssert("created difficulty has effect points", () => + { + // since this difficulty is on another ruleset, scroll speed specifications are completely reset, + // therefore discarding some effect points in the process due to being redundant. + return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] + { + new EffectControlPoint { Time = 500, KiaiMode = true }, + new EffectControlPoint { Time = 1000, KiaiMode = false }, + }); + }); + } + [Test] public void TestCopyDifficulty() { @@ -281,6 +377,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != originalDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has copy suffix in name", () => EditorBeatmap.BeatmapInfo.DifficultyName == copyDifficultyName); AddAssert("created difficulty has timing point", () => { @@ -291,7 +389,9 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); + ensureEditorLoaded(); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); + AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1); AddStep("save beatmap", () => Editor.Save()); @@ -354,6 +454,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != originalDifficultyName; }); + ensureEditorLoaded(); + AddStep("save without changes", () => Editor.Save()); AddAssert("collection still points to old beatmap", () => !collection.BeatmapMD5Hashes.Contains(EditorBeatmap.BeatmapInfo.MD5Hash) @@ -391,6 +493,9 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != "New Difficulty"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty has correct name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "New Difficulty (1)"); AddAssert("new difficulty persisted", () => { @@ -428,6 +533,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != duplicate_difficulty_name; }); + ensureEditorLoaded(); + AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddStep("try to save beatmap", () => Editor.Save()); AddAssert("beatmap set not corrupted", () => @@ -454,6 +561,8 @@ namespace osu.Game.Tests.Visual.Editing return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1); }); + ensureEditorLoaded(); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); AddUntilStep("wait for created", () => @@ -461,7 +570,8 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != duplicate_difficulty_name; }); - AddUntilStep("wait for editor load", () => Editor.IsLoaded && DialogOverlay.IsLoaded); + + ensureEditorLoaded(); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { @@ -498,6 +608,9 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); @@ -524,11 +637,240 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty (1)"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 3 && s.Files.Count == 3); }); } + + [Test] + public void TestSingleBackgroundFile() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(1); + + AddAssert("set background on second diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg")); + + switchToDifficulty(0); + + AddAssert("set background on first diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (2).jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (2).jpg")); + + AddAssert("set background on all diff", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg")); + AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg" || f.Filename == "bg (2).jpg")); + } + + [Test] + public void TestBackgroundFileChangesPreserveOnEncode() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(0); + + AddAssert("set different background on all diff", () => setBackgroundDifferentExtension(applyToAllDifficulties: true, expected: "bg.jpeg")); + AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpeg")); + AddAssert("all diff encode same background", () => + { + return Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => + { + var files = new RealmFileStore(Realm, Dependencies.Get().Storage); + using var store = new RealmBackedResourceStore(b.BeatmapSet!.ToLive(Realm), files.Store, Realm); + string[] osu = Encoding.UTF8.GetString(store.Get(b.File!.Filename)).Split(Environment.NewLine); + Assert.That(osu, Does.Contain("0,0,\"bg.jpeg\",0,0")); + return true; + }); + }); + } + + [Test] + public void TestSingleAudioFile() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(1); + + AddAssert("set audio on second diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3")); + + switchToDifficulty(0); + + AddAssert("set audio on first diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (2).mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (2).mp3")); + + AddAssert("set audio on all diff", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); + AddAssert("all diff uses one audio", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.AudioFile == "audio.mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3")); + AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3" || f.Filename == "audio (2).mp3")); + } + + [Test] + public void TestMultipleBackgroundFiles() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg")); + + createNewDifficulty(); + + AddAssert("new difficulty uses same background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg"); + AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg")); + AddAssert("new difficulty uses new background", () => Beatmap.Value.Metadata.BackgroundFile == "bg (1).jpg"); + + switchToDifficulty(0); + + AddAssert("old difficulty uses old background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg"); + AddAssert("old background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg")); + AddStep("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg")); + AddAssert("other background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg")); + } + + [Test] + public void TestMultipleAudioFiles() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3")); + + createNewDifficulty(); + + AddAssert("new difficulty uses same audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3"); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3")); + AddAssert("new difficulty uses new audio", () => Beatmap.Value.Metadata.AudioFile == "audio (1).mp3"); + + switchToDifficulty(0); + + AddAssert("old difficulty uses old audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3"); + AddAssert("old audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3")); + AddStep("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3")); + AddAssert("other audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3")); + } + + private void ensureEditorLoaded() => AddUntilStep("wait for editor load", () => Editor.IsLoaded && DialogOverlay.IsLoaded); + + private void createNewDifficulty() + { + string? currentDifficulty = null; + + AddStep("save", () => Editor.Save()); + AddStep("create new difficulty", () => + { + currentDifficulty = EditorBeatmap.BeatmapInfo.DifficultyName; + Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo); + }); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); + AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction()); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != currentDifficulty; + }); + ensureEditorLoaded(); + + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + } + + private void switchToDifficulty(int index) + { + AddStep("save", () => Editor.Save()); + AddStep($"switch to difficulty #{index + 1}", () => + Editor.SwitchToDifficulty(Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(index))); + + ensureEditorLoaded(); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + } + + private bool setBackground(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder => + { + bool success = setup.ChildrenOfType().First().ChangeBackgroundImage( + new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpg")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setBackgroundDifferentExtension(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder => + { + File.Move( + Path.Combine(extractedFolder, @"machinetop_background.jpg"), + Path.Combine(extractedFolder, @"machinetop_background.jpeg")); + + bool success = setup.ChildrenOfType().First().ChangeBackgroundImage( + new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpeg")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setAudio(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetTestBeatmapForImport(), extractedFolder => + { + bool success = setup.ChildrenOfType().First().ChangeAudioTrack( + new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.AudioFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setFile(string archivePath, Func func) + { + string temp = archivePath; + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + return func(extractedFolder); + } + finally + { + File.Delete(temp); + Directory.Delete(extractedFolder, true); + } + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index a766b253aa..ce9dbd5fb1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(newTime, null)); } [Test] @@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Editing [TestCase(true)] public void TestCopyPaste(bool deselectAfterCopy) { + const int paste_time = 2000; + var addedObject = new HitCircle { StartTime = 1000 }; AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); @@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("copy hitobject", () => Editor.Copy()); - AddStep("move forward in time", () => EditorClock.Seek(2000)); + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); if (deselectAfterCopy) { @@ -144,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(paste_time, null)); AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs new file mode 100644 index 0000000000..edaba67591 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneEditorClipboardSnapping : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + private const double beat_length = 60_000 / 180.0; // 180 bpm + private const double timing_point_time = 1500; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(timing_point_time, new TimingControlPoint { BeatLength = beat_length }); + return new TestBeatmap(ruleset, false) + { + ControlPointInfo = controlPointInfo + }; + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestPasteSnapping(int divisor) + { + const double paste_time = timing_point_time + 1271; // arbitrary timestamp that doesn't snap to the timing point at any divisor + + var addedObjects = new HitObject[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1200 }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + AddStep("select added objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + AddStep("copy hitobjects", () => Editor.Copy()); + + AddStep($"set beat divisor to 1/{divisor}", () => + { + var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); + beatDivisor.SetArbitraryDivisor(divisor); + }); + + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); + AddAssert("not at snapped time", () => EditorClock.CurrentTime != EditorBeatmap.SnapTime(EditorClock.CurrentTime, null)); + + AddStep("paste hitobjects", () => Editor.Paste()); + + AddAssert("first object is snapped", () => Precision.AlmostEquals( + EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!.StartTime, + EditorBeatmap.ControlPointInfo.GetClosestSnappedTime(paste_time, divisor) + )); + + AddAssert("duration between pasted objects is same", () => + { + var firstObject = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!; + var secondObject = EditorBeatmap.SelectedHitObjects.MaxBy(h => h.StartTime)!; + + return Precision.AlmostEquals(secondObject.StartTime - firstObject.StartTime, addedObjects[1].StartTime - addedObjects[0].StartTime); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index b487fa3cec..2e7b55ab49 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Set beat divisor", () => Editor.Dependencies.Get().Value = 16); AddStep("Set timeline zoom", () => { - originalTimelineZoom = EditorBeatmap.BeatmapInfo.TimelineZoom; + originalTimelineZoom = EditorBeatmap.TimelineZoom; var timeline = Editor.ChildrenOfType().Single(); InputManager.MoveMouseTo(timeline); @@ -81,19 +81,19 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Ensure timeline zoom changed", () => { - changedTimelineZoom = EditorBeatmap.BeatmapInfo.TimelineZoom; + changedTimelineZoom = EditorBeatmap.TimelineZoom; return !Precision.AlmostEquals(changedTimelineZoom, originalTimelineZoom); }); SaveEditor(); AddAssert("Beatmap has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor == 16); - AddAssert("Beatmap has correct timeline zoom", () => EditorBeatmap.BeatmapInfo.TimelineZoom == changedTimelineZoom); + AddAssert("Beatmap has correct timeline zoom", () => EditorBeatmap.TimelineZoom == changedTimelineZoom); ReloadEditorToSameBeatmap(); AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor == 16); - AddAssert("Beatmap still has correct timeline zoom", () => EditorBeatmap.BeatmapInfo.TimelineZoom == changedTimelineZoom); + AddAssert("Beatmap still has correct timeline zoom", () => EditorBeatmap.TimelineZoom == changedTimelineZoom); } [Test] @@ -208,5 +208,11 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(7)); AddAssert("Correct beat divisor actually active", () => Editor.BeatDivisor, () => Is.EqualTo(7)); } + + [Test] + public void TestBeatmapVersionPopulatedCorrectly() + { + AddAssert("beatmap version is populated", () => EditorBeatmap.BeatmapVersion > 0); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 677d3135ba..e584f1b9d7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Editing beatmap.ControlPointInfo.Add(50000, new DifficultyControlPoint { SliderVelocity = 2 }); beatmap.ControlPointInfo.Add(80000, new EffectControlPoint { KiaiMode = true }); beatmap.ControlPointInfo.Add(110000, new EffectControlPoint { KiaiMode = false }); - beatmap.BeatmapInfo.Bookmarks = new[] { 75000, 125000 }; + beatmap.Bookmarks = new[] { 75000, 125000 }; beatmap.Breaks.Add(new ManualBreakPeriod(90000, 120000)); editorBeatmap = new EditorBeatmap(beatmap); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 23efb40d3f..f65a3e67e8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -7,18 +7,18 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; +using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Play; @@ -42,14 +42,6 @@ namespace osu.Game.Tests.Visual.Editing private BeatmapSetInfo importedBeatmapSet; - private Bindable editorDim; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - editorDim = config.GetBindable(OsuSetting.EditorDim); - } - public override void SetUpSteps() { AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game).GetResultSafely()); @@ -80,15 +72,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddUntilStep("background has correct params", () => - { - // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ - // due to the beatmap refetch logic ran on editor suspend. - // this test cares about checking the background belonging to the editor specifically, so check that using reference equality - // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). - var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; - }); + AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen); AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); } @@ -113,20 +97,41 @@ namespace osu.Game.Tests.Visual.Editing AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddUntilStep("background has correct params", () => - { - // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ - // due to the beatmap refetch logic ran on editor suspend. - // this test cares about checking the background belonging to the editor specifically, so check that using reference equality - // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). - var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; - }); + AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen); AddStep("start track", () => EditorClock.Start()); AddAssert("sample playback re-enabled", () => !Editor.SamplePlaybackDisabled.Value); } + [Test] + public void TestGameplayTestResetsPlaybackSpeedAdjustment() + { + AddStep("start track", () => EditorClock.Start()); + AddStep("change playback speed", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25)); + + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddAssert("editor track stopped", () => !EditorClock.IsRunning); + AddAssert("track playback rate is 1x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(1)); + + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25)); + } + [TestCase(2000)] // chosen to be after last object in the map [TestCase(22000)] // chosen to be in the middle of the last spinner public void TestGameplayTestAtEndOfBeatmap(int offsetFromEnd) @@ -177,6 +182,7 @@ namespace osu.Game.Tests.Visual.Editing // bit of a hack to ensure this test can be ran multiple times without running into UNIQUE constraint failures AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = Guid.NewGuid().ToString()); + AddStep("start playing track", () => InputManager.Key(Key.Space)); AddStep("click test gameplay button", () => { var button = Editor.ChildrenOfType().Single(); @@ -185,11 +191,13 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Click(MouseButton.Left); }); AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog); + AddAssert("track stopped", () => !Beatmap.Value.Track.IsRunning); AddStep("save changes", () => DialogOverlay.CurrentDialog!.PerformOkAction()); EditorPlayer editorPlayer = null; AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddUntilStep("track playing", () => Beatmap.Value.Track.IsRunning); AddAssert("beatmap has 1 object", () => editorPlayer.Beatmap.Value.Beatmap.HitObjects.Count == 1); AddUntilStep("wait for return to editor", () => Stack.CurrentScreen is Editor); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index d7c92a64b1..440e002021 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -205,7 +205,7 @@ namespace osu.Game.Tests.Visual.Editing { double originalSpacing = 0; - AddStep("retrieve original spacing", () => originalSpacing = editorBeatmap.BeatmapInfo.DistanceSpacing); + AddStep("retrieve original spacing", () => originalSpacing = editorBeatmap.DistanceSpacing); AddStep("hold ctrl", () => InputManager.PressKey(Key.LControl)); AddStep("hold alt", () => InputManager.PressKey(Key.LAlt)); @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release alt", () => InputManager.ReleaseKey(Key.LAlt)); AddStep("release ctrl", () => InputManager.ReleaseKey(Key.LControl)); - AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); + AddAssert("distance spacing increased by 0.5", () => editorBeatmap.DistanceSpacing == originalSpacing + 0.5); } public partial class EditorBeatmapContainer : PopoverContainer diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs index 7f9a69833c..636b3f54d8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Extensions; +using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Tests.Resources; @@ -25,13 +26,16 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestLocallyModifyingOnlineBeatmap() { + string initialHash = string.Empty; AddAssert("editor beatmap has online ID", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.GreaterThan(0)); + AddStep("store hash for later", () => initialHash = EditorBeatmap.BeatmapInfo.MD5Hash); AddStep("delete first hitobject", () => EditorBeatmap.RemoveAt(0)); SaveEditor(); ReloadEditorToSameBeatmap(); - AddAssert("editor beatmap online ID reset", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.EqualTo(-1)); + AddAssert("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); + AddAssert("beatmap hash changed", () => EditorBeatmap.BeatmapInfo.MD5Hash, () => Is.Not.EqualTo(initialHash)); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index 966e6513bb..ae20f5e5cf 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; @@ -14,8 +15,10 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Beatmaps; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -58,23 +61,66 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestContextMenu() + public void TestRightClickDuringEmptyPlacementTogglesNewCombo() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("move mouse away from placed circle", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One)); + + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + AddAssert("new combo true", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.True)); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + } + + [Test] + public void TestRightClickDuringPlacementDeletes() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(0).Items); + AddAssert("circle not selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Exactly(0).Items); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + } + + [Test] + public void TestRightClickDuringSelectionShowsContextMenu() { AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); AddStep("place circle", () => InputManager.Click(MouseButton.Left)); - AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); - AddStep("delete with right mouse", () => - { - InputManager.Click(MouseButton.Right); - }); - AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.One.Items); + // ensure the circle we're selecting is not a new combo so we can assert + // new combo doesn't happen to get toggled by right click. + AddStep("seek forward", () => EditorClock.Seek(1000)); + AddStep("place second circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("two circles added", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + + AddStep("select selection tool", () => InputManager.Key(Key.Number1)); + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + + AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items); AddAssert("circle selected", () => EditorBeatmap.SelectedHitObjects, () => Has.One.Items); + AddAssert("context menu visible", () => Editor.ChildrenOfType().Any(c => c.IsPresent)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); } [Test] - [Solo] public void TestCommitPlacementViaRightClick() { Playfield playfield = null!; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs new file mode 100644 index 0000000000..47414bb24e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Submission; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneSubmissionStageProgress : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Test] + public void TestAppearance() + { + SubmissionStageProgress progress = null!; + + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = progress = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Frobnicating the foobarator...", + } + }); + AddStep("not started", () => progress.SetNotStarted()); + AddStep("indeterminate progress", () => progress.SetInProgress()); + AddStep("30% progress", () => progress.SetInProgress(0.3f)); + AddStep("70% progress", () => progress.SetInProgress(0.7f)); + AddStep("completed", () => progress.SetCompleted()); + AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated")); + AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe")); + AddStep("canceled", () => progress.SetCanceled()); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index cf07ce2431..eecfb7cb6e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Editing private TimingScreen timingScreen; private EditorBeatmap editorBeatmap; + private BeatmapEditorChangeHandler changeHandler; protected override bool ScrollUsingMouseWheel => false; @@ -46,6 +47,7 @@ namespace osu.Game.Tests.Visual.Editing private void reloadEditorBeatmap() { editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(Ruleset.Value)); + changeHandler = new BeatmapEditorChangeHandler(editorBeatmap); Child = new DependencyProvidingContainer { @@ -53,6 +55,7 @@ namespace osu.Game.Tests.Visual.Editing CachedDependencies = new (Type, object)[] { (typeof(EditorBeatmap), editorBeatmap), + (typeof(IEditorChangeHandler), changeHandler), (typeof(IBeatSnapProvider), editorBeatmap) }, Child = timingScreen = new TimingScreen @@ -72,8 +75,10 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Wait for rows to load", () => Child.ChildrenOfType().Any()); } + // TODO: this is best-effort for now, but the comment out test below should probably be how things should work. + // Was originally working as of https://github.com/ppy/osu/pull/26141; Regressed at some point. [Test] - public void TestSelectedRetainedOverUndo() + public void TestSelectionDismissedOnUndo() { AddStep("Select first timing point", () => { @@ -95,25 +100,52 @@ namespace osu.Game.Tests.Visual.Editing return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; }); - AddStep("simulate undo", () => - { - var clone = editorBeatmap.ControlPointInfo.DeepClone(); + AddStep("undo", () => changeHandler?.RestoreState(-1)); - editorBeatmap.ControlPointInfo.Clear(); - - foreach (var group in clone.Groups) - { - foreach (var cp in group.ControlPoints) - editorBeatmap.ControlPointInfo.Add(group.Time, cp); - } - }); - - AddUntilStep("selection retained", () => - { - return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; - }); + AddUntilStep("selection dismissed", () => timingScreen.SelectedGroup.Value, () => Is.Null); } + // [Test] + // public void TestSelectedRetainedOverUndo() + // { + // AddStep("Select first timing point", () => + // { + // InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 2170); + // AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 2170); + // + // AddStep("Adjust offset", () => + // { + // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0)); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddUntilStep("wait for offset changed", () => + // { + // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; + // }); + // + // AddStep("undo", () => changeHandler?.RestoreState(-1)); + // + // AddUntilStep("selection retained", () => + // { + // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; + // }); + // + // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10)); + // + // AddStep("Adjust offset", () => + // { + // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0)); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10)); + // } + [Test] public void TestScrollControlGroupIntoView() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 1c8a18e131..2c84e76b2e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -128,12 +128,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft)); AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(0, 3))); AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); - AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); + AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X)); // Scroll out at 0.25 AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(0, -3))); AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); - AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); + AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X)); AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 0f47c3cd27..aa99b22701 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -27,18 +27,7 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUpSteps] public void SetUpSteps() { - AddStep("Create control", () => - { - Child = new PlayerSettingsGroup("Some settings") - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - offsetControl = new BeatmapOffsetControl() - } - }; - }); + recreateControl(); } [Test] @@ -123,13 +112,14 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestCalibrationFromZero() { + ScoreInfo referenceScore = null!; const double average_error = -4.5; AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); AddStep("Set reference score", () => { - offsetControl.ReferenceScore.Value = new ScoreInfo + offsetControl.ReferenceScore.Value = referenceScore = new ScoreInfo { HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), BeatmapInfo = Beatmap.Value.BeatmapInfo, @@ -143,6 +133,10 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + + recreateControl(); + AddStep("Set same reference score", () => offsetControl.ReferenceScore.Value = referenceScore); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } /// @@ -251,5 +245,21 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + + private void recreateControl() + { + AddStep("Create control", () => + { + Child = new PlayerSettingsGroup("Some settings") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + offsetControl = new BeatmapOffsetControl() + } + }; + }); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs index 21b6495865..844f5cba01 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -40,11 +40,16 @@ namespace osu.Game.Tests.Visual.Gameplay RelativeSizeAxes = Axes.Both, }, breakTracker = new TestBreakTracker(), - breakOverlay = new BreakOverlay(true, new ScoreProcessor(new OsuRuleset())) + breakOverlay = new BreakOverlay(new ScoreProcessor(new OsuRuleset())) { ProcessCustomClock = false, BreakTracker = breakTracker, - } + }, + new LetterboxOverlay + { + ProcessCustomClock = false, + BreakTracker = breakTracker, + }, }; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs index db06329d74..55d57d7a65 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs @@ -120,6 +120,7 @@ namespace osu.Game.Tests.Visual.Gameplay public double FramesPerSecond => throw new NotImplementedException(); public FrameTimeInfo TimeInfo => throw new NotImplementedException(); public double StartTime => throw new NotImplementedException(); + public double GameplayStartTime => throw new NotImplementedException(); public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 5af7540f6f..8cbd6249e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay { loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) { - BeatmapInfo = { AudioLeadIn = leadIn } + AudioLeadIn = leadIn }); checkFirstFrameTime(expectedStartTime); @@ -133,7 +133,7 @@ namespace osu.Game.Tests.Visual.Gameplay { Text = $"GameplayStartTime: {DrawableRuleset.GameplayStartTime} " + $"FirstHitObjectTime: {FirstHitObjectTime} " - + $"LeadInTime: {Beatmap.Value.BeatmapInfo.AudioLeadIn} " + + $"LeadInTime: {Beatmap.Value.Beatmap.AudioLeadIn} " + $"FirstFrameClockTime: {FirstFrameClockTime}" }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs deleted file mode 100644 index ce93837925..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Screens.Play.Break; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public partial class TestSceneLetterboxOverlay : OsuTestScene - { - public TestSceneLetterboxOverlay() - { - AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both - }, - new LetterboxOverlay() - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 6aa2c4e40d..58fe6e8e56 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -19,6 +20,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osu.Game.Storyboards; using osuTK; using osuTK.Input; @@ -28,6 +30,12 @@ namespace osu.Game.Tests.Visual.Gameplay { protected new PausePlayer Player => (PausePlayer)base.Player; + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + beatmap.AudioLeadIn = 4000; + return base.CreateWorkingBeatmap(beatmap, storyboard); + } + private readonly Container content; protected override Container Content => content; @@ -200,8 +208,10 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] + [Ignore("Fails on github runners if they happen to skip too far forward in time.")] public void TestUserPauseDuringCooldownTooSoon() { + AddStep("seek to gameplay", () => Player.GameplayClockContainer.Seek(0)); AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); pauseAndConfirm(); @@ -213,9 +223,23 @@ namespace osu.Game.Tests.Visual.Gameplay confirmNotExited(); } + [Test] + public void TestUserPauseDuringIntroSkipsCooldown() + { + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + pauseAndConfirm(); + + resume(); + pauseViaBackAction(); + confirmPaused(); + } + [Test] public void TestQuickExitDuringCooldownTooSoon() { + AddStep("seek to gameplay", () => Player.GameplayClockContainer.Seek(0)); AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); pauseAndConfirm(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 182193714b..c8b7ccc3d0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -136,10 +136,10 @@ namespace osu.Game.Tests.Visual.Gameplay var workingBeatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); // Add intro time to test quick retry skipping (TestQuickRetry). - workingBeatmap.BeatmapInfo.AudioLeadIn = 60000; + workingBeatmap.Beatmap.AudioLeadIn = 60000; // Set up data for testing disclaimer display. - workingBeatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning ?? false; + workingBeatmap.Beatmap.EpilepsyWarning = epilepsyWarning ?? false; workingBeatmap.BeatmapInfo.Status = onlineStatus ?? BeatmapOnlineStatus.Ranked; Beatmap.Value = workingBeatmap; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index 1660f93384..046ae6d953 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -221,7 +221,7 @@ namespace osu.Game.Tests.Visual.Gameplay string? filePath = null; // Files starting with _ are temporary, created by CreateFileSafely call. - AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null); + AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith('_')), () => Is.Not.Null); AddUntilStep("filesize is non-zero", () => { try diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index c382f0828b..381f49d9eb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -16,6 +16,7 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -234,6 +235,31 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } + [Test] + public void TestNoSubmissionWhenScoreZero() + { + prepareTestAPI(true); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddUntilStep("wait for first result", () => Player.Results.Count > 0); + + AddStep("add fake non-scoring hit", () => + { + Player.ScoreProcessor.RevertResult(Player.Results.First()); + Player.ScoreProcessor.ApplyResult(new OsuJudgementResult(Beatmap.Value.Beatmap.HitObjects.First(), new IgnoreJudgement()) + { + Type = HitResult.IgnoreHit, + }); + }); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + [Test] public void TestSubmissionOnExit() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index ae10207de0..81dd23661c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay { loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) { - BeatmapInfo = { AudioLeadIn = 60000 } + AudioLeadIn = 60000 }); AddUntilStep("wait for skip overlay", () => Player.ChildrenOfType().First().IsButtonVisible); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index a7ab021884..4ad6bc66e3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -15,6 +15,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.StateChanges; using osu.Framework.Testing; using osu.Framework.Threading; +using osu.Framework.Timing; using osu.Game.Graphics.Sprites; using osu.Game.Replays; using osu.Game.Rulesets; @@ -42,6 +43,8 @@ namespace osu.Game.Tests.Visual.Gameplay private GameplayState gameplayState; + private Drawable content; + [SetUpSteps] public void SetUpSteps() { @@ -58,7 +61,7 @@ namespace osu.Game.Tests.Visual.Gameplay { RelativeSizeAxes = Axes.Both, CachedDependencies = new (Type, object)[] { (typeof(GameplayState), gameplayState) }, - Child = createContent(), + Child = content = createContent(), }; }); } @@ -67,10 +70,32 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestBasic() { AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("at least one frame recorded", () => replay.Frames.Count > 0); + AddUntilStep("at least one frame recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(0)); AddUntilStep("position matches", () => playbackManager.ChildrenOfType().First().Position == recordingManager.ChildrenOfType().First().Position); } + [Test] + [Explicit("Making this test work in a headless context is high effort due to rate adjustment requirements not aligning with the global fast clock. StopwatchClock usage would need to be replace with a rate adjusting clock that still reads from the parent clock. High effort for a test which likely will not see any changes to covered code for some years.")] + public void TestSlowClockStillRecordsFramesInRealtime() + { + ScheduledDelegate moveFunction = null; + + AddStep("set slow running clock", () => + { + var stopwatchClock = new StopwatchClock(true) { Rate = 0.01 }; + stopwatchClock.Seek(Clock.CurrentTime); + + content.Clock = new FramedClock(stopwatchClock); + }); + + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move", () => moveFunction = Scheduler.AddDelayed(() => + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); + } + [Test] public void TestHighFrameRate() { @@ -81,7 +106,7 @@ namespace osu.Game.Tests.Visual.Gameplay InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); } [Test] @@ -97,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("less than 10 frames recorded", () => replay.Frames.Count - initialFrameCount < 10); + AddAssert("less than 10 frames recorded", () => replay.Frames.Count - initialFrameCount, () => Is.LessThan(10)); } [Test] @@ -114,7 +139,7 @@ namespace osu.Game.Tests.Visual.Gameplay }, 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); } protected override void Update() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs new file mode 100644 index 0000000000..1445e872b5 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public partial class TestSceneSpectatorList : OsuTestScene + { + private int counter; + + [Test] + public void TestBasics() + { + SpectatorList list = null!; + Bindable playingState = new Bindable(); + GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState); + TestSpectatorClient spectatorClient = new TestSpectatorClient(); + TestMultiplayerClient multiplayerClient = new TestMultiplayerClient(new TestRoomRequestsHandler()); + + AddStep("create spectator list", () => + { + Children = new Drawable[] + { + spectatorClient, + multiplayerClient, + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(SpectatorClient), spectatorClient), + (typeof(MultiplayerClient), multiplayerClient), + ], + Child = list = new SpectatorList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + }); + + AddStep("start playing", () => playingState.Value = LocalUserPlayingState.Playing); + + AddRepeatStep("add a user", () => + { + int id = Interlocked.Increment(ref counter); + ((ISpectatorClient)spectatorClient).UserStartedWatching([ + new SpectatorUser + { + OnlineID = id, + Username = $"User {id}" + } + ]); + }, 10); + + AddRepeatStep("remove random user", () => ((ISpectatorClient)spectatorClient).UserEndedWatching( + spectatorClient.WatchingUsers[RNG.Next(spectatorClient.WatchingUsers.Count)].OnlineID), 5); + + AddStep("change font to venera", () => list.HeaderFont.Value = Typeface.Venera); + AddStep("change font to torus", () => list.HeaderFont.Value = Typeface.Torus); + AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1)); + + AddStep("enter break", () => playingState.Value = LocalUserPlayingState.Break); + AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index e918a93cbc..5da60966b2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load storyboard with only video", () => { // LegacyStoryboardDecoder doesn't parse WidescreenStoryboard, so it is set manually - loadStoryboard("storyboard_only_video.osu", s => s.BeatmapInfo.WidescreenStoryboard = false); + loadStoryboard("storyboard_only_video.osu", s => s.Beatmap.WidescreenStoryboard = false); }); AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f)); diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index b09dbc1a91..2b0717c1e3 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Menus protected OsuScreenStack IntroStack; - private IntroScreen intro; + protected IntroScreen Intro { get; private set; } [Cached(typeof(INotificationOverlay))] private NotificationOverlay notifications; @@ -62,22 +62,9 @@ namespace osu.Game.Tests.Visual.Menus [Test] public virtual void TestPlayIntro() { - AddStep("restart sequence", () => - { - logo.FinishTransforms(); - logo.IsTracking = false; + RestartIntro(); - IntroStack?.Expire(); - - Add(IntroStack = new OsuScreenStack - { - RelativeSizeAxes = Axes.Both, - }); - - IntroStack.Push(intro = CreateScreen()); - }); - - AddUntilStep("wait for menu", () => intro.DidLoadMenu); + WaitForMenu(); } [Test] @@ -103,18 +90,18 @@ namespace osu.Game.Tests.Visual.Menus RelativeSizeAxes = Axes.Both, }); - IntroStack.Push(intro = CreateScreen()); + IntroStack.Push(Intro = CreateScreen()); }); AddStep("trigger failure", () => { trackResetDelegate = Scheduler.AddDelayed(() => { - intro.Beatmap.Value.Track.Seek(0); + Intro.Beatmap.Value.Track.Seek(0); }, 0, true); }); - AddUntilStep("wait for menu", () => intro.DidLoadMenu); + WaitForMenu(); if (IntroReliesOnTrack) AddUntilStep("wait for notification", () => notifications.UnreadCount.Value == 1); @@ -122,6 +109,29 @@ namespace osu.Game.Tests.Visual.Menus AddStep("uninstall delegate", () => trackResetDelegate?.Cancel()); } + protected void RestartIntro() + { + AddStep("restart sequence", () => + { + logo.FinishTransforms(); + logo.IsTracking = false; + + IntroStack?.Expire(); + + Add(IntroStack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }); + + IntroStack.Push(Intro = CreateScreen()); + }); + } + + protected void WaitForMenu() + { + AddUntilStep("wait for menu", () => Intro.DidLoadMenu); + } + protected abstract IntroScreen CreateScreen(); } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.cs new file mode 100644 index 0000000000..0398b4fbb6 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Screens.Menu; +using osu.Game.Seasonal; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public partial class TestSceneIntroChristmas : IntroTestScene + { + protected override bool IntroReliesOnTrack => true; + protected override IntroScreen CreateScreen() => new IntroChristmas(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs new file mode 100644 index 0000000000..a5590c79ae --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [HeadlessTest] + [TestFixture] + public partial class TestSceneIntroIntegrity : IntroTestScene + { + [Test] + public virtual void TestDeletedFilesRestored() + { + RestartIntro(); + WaitForMenu(); + + AddStep("delete game files unexpectedly", () => LocalStorage.DeleteDirectory("files")); + AddStep("reset game beatmap", () => Dependencies.Get>().Value = new DummyWorkingBeatmap(Audio, null)); + AddStep("invalidate beatmap from cache", () => Dependencies.Get().Invalidate(Intro.Beatmap.Value.BeatmapSetInfo)); + + RestartIntro(); + WaitForMenu(); + + AddUntilStep("ensure track is not virtual", () => Intro.Beatmap.Value.Track is TrackBass); + } + + protected override bool IntroReliesOnTrack => true; + protected override IntroScreen CreateScreen() => new IntroTriangles(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 609bc6e166..3c97b291ee 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -29,9 +29,7 @@ namespace osu.Game.Tests.Visual.Menus private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; private LoginOverlay loginOverlay = null!; - - [Resolved] - private OsuConfigManager configManager { get; set; } = null!; + private OsuConfigManager localConfig = null!; [Cached(typeof(LocalUserStatisticsProvider))] private readonly TestSceneUserPanel.TestUserStatisticsProvider statisticsProvider = new TestSceneUserPanel.TestUserStatisticsProvider(); @@ -39,6 +37,8 @@ namespace osu.Game.Tests.Visual.Menus [BackgroundDependencyLoader] private void load() { + Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); + Child = loginOverlay = new LoginOverlay { Anchor = Anchor.Centre, @@ -49,6 +49,7 @@ namespace osu.Game.Tests.Visual.Menus [SetUpSteps] public void SetUpSteps() { + AddStep("reset online state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.Online)); AddStep("show login overlay", () => loginOverlay.Show()); } @@ -89,7 +90,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("clear handler", () => dummyAPI.HandleRequest = null); assertDropdownState(UserAction.Online); - AddStep("change user state", () => dummyAPI.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); assertDropdownState(UserAction.DoNotDisturb); } @@ -188,31 +189,31 @@ namespace osu.Game.Tests.Visual.Menus public void TestUncheckingRememberUsernameClearsIt() { AddStep("logout", () => API.Logout()); - AddStep("set username", () => configManager.SetValue(OsuSetting.Username, "test_user")); - AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true)); + AddStep("set username", () => localConfig.SetValue(OsuSetting.Username, "test_user")); + AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true)); AddStep("uncheck remember username", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); - AddAssert("remember username off", () => configManager.Get(OsuSetting.SaveUsername), () => Is.False); - AddAssert("remember password off", () => configManager.Get(OsuSetting.SavePassword), () => Is.False); - AddAssert("username cleared", () => configManager.Get(OsuSetting.Username), () => Is.Empty); + AddAssert("remember username off", () => localConfig.Get(OsuSetting.SaveUsername), () => Is.False); + AddAssert("remember password off", () => localConfig.Get(OsuSetting.SavePassword), () => Is.False); + AddAssert("username cleared", () => localConfig.Get(OsuSetting.Username), () => Is.Empty); } [Test] public void TestUncheckingRememberPasswordClearsToken() { AddStep("logout", () => API.Logout()); - AddStep("set token", () => configManager.SetValue(OsuSetting.Token, "test_token")); - AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true)); + AddStep("set token", () => localConfig.SetValue(OsuSetting.Token, "test_token")); + AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true)); AddStep("uncheck remember token", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().Last()); InputManager.Click(MouseButton.Left); }); - AddAssert("remember password off", () => configManager.Get(OsuSetting.SavePassword), () => Is.False); - AddAssert("token cleared", () => configManager.Get(OsuSetting.Token), () => Is.Empty); + AddAssert("remember password off", () => localConfig.Get(OsuSetting.SavePassword), () => Is.False); + AddAssert("token cleared", () => localConfig.Get(OsuSetting.Token), () => Is.Empty); } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs index f3ea20c1aa..cd391519f4 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Menus new APIMenuImage { Image = @"https://assets.ppy.sh/main-menu/project-loved-2@2x.png", - Url = $@"{API.WebsiteRootUrl}/home/news/2023-12-21-project-loved-december-2023", + Url = $@"{API.Endpoints.WebsiteUrl}/home/news/2023-12-21-project-loved-december-2023", } } }); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.cs new file mode 100644 index 0000000000..46fddf823e --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Seasonal; + +namespace osu.Game.Tests.Visual.Menus +{ + public partial class TestSceneMainMenuSeasonalLighting : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("prepare beatmap", () => + { + var setInfo = beatmaps.QueryBeatmapSet(b => b.Protected && b.Hash == IntroChristmas.CHRISTMAS_BEATMAP_SET_HASH); + + if (setInfo != null) + Beatmap.Value = beatmaps.GetWorkingBeatmap(setInfo.Value.Beatmaps.First()); + }); + + AddStep("create lighting", () => Child = new MainMenuSeasonalLighting()); + + AddStep("restart beatmap", () => + { + Beatmap.Value.Track.Start(); + Beatmap.Value.Track.Seek(4000); + }); + } + + [Test] + public void TestBasic() + { + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs index 0d981014b8..396d2e9027 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs @@ -50,30 +50,17 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestGameplay() { + KiaiGameplayFountains fountains = null!; + AddStep("make fountains", () => { Children = new[] { - new KiaiGameplayFountains.GameplayStarFountain - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - X = 75, - }, - new KiaiGameplayFountains.GameplayStarFountain - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - X = -75, - }, + fountains = new KiaiGameplayFountains(), }; }); - AddStep("activate fountains", () => - { - ((StarFountain)Children[0]).Shoot(1); - ((StarFountain)Children[1]).Shoot(-1); - }); + AddStep("activate fountains", () => fountains.Shoot()); } [Test] diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs index f4732234a7..a7447a92cd 100644 --- a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs +++ b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Mods protected override TestPlayer CreateModPlayer(Ruleset ruleset) { var player = base.CreateModPlayer(ruleset); - player.RestartRequested = _ => restartRequested = true; + player.PrepareLoaderForRestart = _ => restartRequested = true; return player; } diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 2b738743ea..0e01751d76 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -44,14 +44,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs index c5fb52461a..459a90d096 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load() { - var mockLounge = new Mock(); + var mockLounge = new Mock(); mockLounge .Setup(l => l.Join(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>())) .Callback, Action>((_, _, _, d) => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index e5938a796c..021c0abf1d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -14,7 +14,6 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Lounge; @@ -76,7 +75,6 @@ namespace osu.Game.Tests.Visual.Multiplayer createLoungeRoom(new Room { Name = "Multiplayer room", - Status = new RoomStatusOpen(), EndDate = DateTimeOffset.Now.AddDays(1), Type = MatchType.HeadToHead, Playlist = [item1], @@ -85,7 +83,6 @@ namespace osu.Game.Tests.Visual.Multiplayer createLoungeRoom(new Room { Name = "Private room", - Status = new RoomStatusOpenPrivate(), Password = "*", EndDate = DateTimeOffset.Now.AddDays(1), Type = MatchType.HeadToHead, @@ -95,36 +92,38 @@ namespace osu.Game.Tests.Visual.Multiplayer createLoungeRoom(new Room { Name = "Playlist room with multiple beatmaps", - Status = new RoomStatusPlaying(), + Status = RoomStatus.Playing, EndDate = DateTimeOffset.Now.AddDays(1), Playlist = [item1, item2], CurrentPlaylistItem = item1 }), createLoungeRoom(new Room { - Name = "Finished room", - Status = new RoomStatusEnded(), + Name = "Closing soon", + EndDate = DateTimeOffset.Now.AddSeconds(5), + }), + createLoungeRoom(new Room + { + Name = "Closed room", EndDate = DateTimeOffset.Now, }), createLoungeRoom(new Room { Name = "Spotlight room", - Status = new RoomStatusOpen(), Category = RoomCategory.Spotlight, }), createLoungeRoom(new Room { Name = "Featured artist room", - Status = new RoomStatusOpen(), Category = RoomCategory.FeaturedArtist, }), } }; }); - AddUntilStep("wait for panel load", () => rooms.Count == 6); + AddUntilStep("wait for panel load", () => rooms.Count == 7); AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Currently playing", StringComparison.Ordinal)) == 2); - AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)) == 4); + AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)) == 5); } [Test] @@ -136,7 +135,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create room", () => Child = drawableRoom = createLoungeRoom(room = new Room { Name = "Room with password", - Status = new RoomStatusOpen(), Type = MatchType.HeadToHead, })); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs index c1662bf944..2fd1268c8a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs @@ -15,6 +15,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene { + private Room room = null!; private DrawableRoomParticipantsList list = null!; public override void SetUpSteps() @@ -23,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create list", () => { - SelectedRoom.Value = new Room + room = new Room { Name = "test room", Host = new APIUser @@ -33,7 +34,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }; - Child = list = new DrawableRoomParticipantsList(SelectedRoom.Value) + Child = list = new DrawableRoomParticipantsList(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -119,7 +120,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); AddAssert("46 hidden users", () => list.ChildrenOfType().Single().Count == 46); - AddStep("remove from end", () => removeUserAt(SelectedRoom.Value!.RecentParticipants.Count - 1)); + AddStep("remove from end", () => removeUserAt(room.RecentParticipants.Count - 1)); AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45); @@ -138,18 +139,18 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addUser(int id) { - SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Append(new APIUser + room.RecentParticipants = room.RecentParticipants.Append(new APIUser { Id = id, Username = $"User {id}" }).ToArray(); - SelectedRoom.Value!.ParticipantCount++; + room.ParticipantCount++; } private void removeUserAt(int index) { - SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Where(u => !u.Equals(SelectedRoom.Value!.RecentParticipants[index])).ToArray(); - SelectedRoom.Value!.ParticipantCount--; + room.RecentParticipants = room.RecentParticipants.Where(u => !u.Equals(room.RecentParticipants[index])).ToArray(); + room.ParticipantCount--; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index fb54b89a4b..fd589e928a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -166,7 +166,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, Y = -ScreenFooter.HEIGHT, - Current = { BindTarget = freeModSelectOverlay.SelectedMods }, + FreeMods = { BindTarget = freeModSelectOverlay.SelectedMods }, }, footer = new ScreenFooter(), }, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 813a420cbd..e372d63fde 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -16,15 +16,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { + private Room room = null!; + public override void SetUpSteps() { base.SetUpSteps(); AddStep("create area", () => { - SelectedRoom.Value = new Room(); - - Child = new MatchBeatmapDetailArea(SelectedRoom.Value) + Child = new MatchBeatmapDetailArea(room = new Room()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewItem() { - SelectedRoom.Value!.Playlist = SelectedRoom.Value.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + room.Playlist = room.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { - ID = SelectedRoom.Value.Playlist.Count, + ID = room.Playlist.Count, RulesetID = new OsuRuleset().RulesetInfo.OnlineID, RequiredMods = new[] { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 38522db4d4..39ad21d0b0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -61,9 +61,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create leaderboard", () => { - SelectedRoom.Value = new Room { RoomID = 3 }; - - Child = new MatchLeaderboard(SelectedRoom.Value) + Child = new MatchLeaderboard(new Room { RoomID = 3 }) { Origin = Anchor.Centre, Anchor = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index fb9c801fb4..2f1b768ea6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -101,15 +101,13 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public void SetUpSteps() { - PlaylistItem item = null!; - AddStep("reset state", () => { multiplayerClient.Invocations.Clear(); beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); - item = new PlaylistItem(Beatmap.Value.BeatmapInfo) + PlaylistItem item = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID }; @@ -127,7 +125,7 @@ namespace osu.Game.Tests.Visual.Multiplayer multiplayerRoom = new MultiplayerRoom(0) { - Playlist = { TestMultiplayerClient.CreateMultiplayerPlaylistItem(item) }, + Playlist = { new MultiplayerPlaylistItem(item) }, Users = { localUser }, Host = localUser, }; @@ -139,8 +137,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(250, 50), - SelectedItem = new Bindable(item) + Size = new Vector2(250, 50) }; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 3245b3c6a9..60358dfbc4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -24,6 +24,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + AddStep("reset", () => { leaderboard?.RemoveAndDisposeImmediately(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index 85352ada3c..aa98dc59db 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -17,6 +17,7 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; @@ -42,6 +43,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmapManager { get; set; } = null!; private MultiSpectatorScreen spectatorScreen = null!; + private Room room = null!; private readonly List playingUsers = new List(); @@ -63,6 +65,10 @@ namespace osu.Game.Tests.Visual.Multiplayer base.SetUpSteps(); AddStep("clear playing users", () => playingUsers.Clear()); + + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); } [TestCase(1)] @@ -372,7 +378,8 @@ namespace osu.Game.Tests.Visual.Multiplayer sendFrames(getPlayerIds(4), 300); - AddUntilStep("wait for correct track speed", () => Beatmap.Value.Track.Rate, () => Is.EqualTo(1.5)); + AddUntilStep("wait for correct track speed", + () => this.ChildrenOfType().All(player => player.ClockAdjustmentsFromMods.AggregateTempo.Value == 1.5)); } [Test] @@ -406,13 +413,13 @@ namespace osu.Game.Tests.Visual.Multiplayer } /// - /// Tests spectating with a beatmap that has a high value. + /// Tests spectating with a beatmap that has a high value. /// /// This test is not intended not to check the correct initial time value, but only to guard against /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] - public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000); + public void TestAudioLeadIn() => testLeadIn(b => b.Beatmap.AudioLeadIn = 2000); /// /// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element). @@ -455,7 +462,7 @@ namespace osu.Game.Tests.Visual.Multiplayer applyToBeatmap?.Invoke(Beatmap.Value); - LoadScreen(spectatorScreen = new MultiSpectatorScreen(SelectedRoom.Value!, playingUsers.ToArray())); + LoadScreen(spectatorScreen = new MultiSpectatorScreen(room, playingUsers.ToArray())); }); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 9213a52c0e..ae939c7b9e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -33,7 +33,6 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Match; @@ -58,7 +57,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerClient multiplayerClient => multiplayerComponents.MultiplayerClient; - private TestMultiplayerRoomManager roomManager => multiplayerComponents.RoomManager; [Resolved] private OsuConfigManager config { get; set; } = null!; @@ -66,14 +64,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() @@ -257,7 +255,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Playlist = @@ -286,7 +284,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Playlist = @@ -336,7 +334,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Password = "password", @@ -789,7 +787,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", QueueMode = QueueMode.AllPlayers, @@ -807,11 +805,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); AddStep("select room", () => InputManager.Key(Key.Down)); - AddStep("disable polling", () => this.ChildrenOfType().Single().TimeBetweenPolls.Value = 0); + AddStep("disable polling", () => this.ChildrenOfType().Single().TimeBetweenPolls.Value = 0); AddStep("change server-side settings", () => { - roomManager.ServerSideRooms[0].Name = "New name"; - roomManager.ServerSideRooms[0].Playlist = + multiplayerClient.ServerSideRooms[0].Name = "New name"; + multiplayerClient.ServerSideRooms[0].Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { @@ -828,7 +826,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("local room has correct settings", () => { var localRoom = this.ChildrenOfType().Single().Room; - return localRoom.Name == roomManager.ServerSideRooms[0].Name && localRoom.Playlist.Single().ID == 2; + return localRoom.Name == multiplayerClient.ServerSideRooms[0].Name && localRoom.Playlist.Single().ID == 2; }); } @@ -926,7 +924,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -958,7 +956,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs index 9951f62c77..56187f8778 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -16,45 +16,32 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + public partial class TestSceneMultiplayerLoungeSubScreen : MultiplayerTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private LoungeSubScreen loungeScreen = null!; - private Room? lastJoinedRoom; - private string? lastJoinedPassword; + private MultiplayerLoungeSubScreen loungeScreen = null!; public override void SetUpSteps() { base.SetUpSteps(); AddStep("push screen", () => LoadScreen(loungeScreen = new MultiplayerLoungeSubScreen())); - AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); - - AddStep("bind to event", () => - { - lastJoinedRoom = null; - lastJoinedPassword = null; - RoomManager.JoinRoomRequested = onRoomJoined; - }); } [Test] public void TestJoinRoomWithoutPassword() { - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: false)); + createRooms(GenerateRooms(1, withPassword: false)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("join room", () => InputManager.Key(Key.Enter)); - AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); - AddAssert("room join password correct", () => lastJoinedPassword == null); + AddAssert("room joined", () => MultiplayerClient.RoomJoined); } [Test] public void TestPopoverHidesOnBackButton() { - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); @@ -67,18 +54,22 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("hit escape", () => InputManager.Key(Key.Escape)); AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] public void TestPopoverHidesOnLeavingScreen() { - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); AddUntilStep("password prompt appeared", () => InputManager.ChildrenOfType().Any()); AddStep("exit screen", () => Stack.Exit()); AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] @@ -86,16 +77,18 @@ namespace osu.Game.Tests.Visual.Multiplayer { DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "wrong"); AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); - AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); + AddAssert("still at lounge", () => loungeScreen.IsCurrentScreen()); AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] @@ -103,16 +96,18 @@ namespace osu.Game.Tests.Visual.Multiplayer { DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "wrong"); AddStep("press enter", () => InputManager.Key(Key.Enter)); - AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); + AddAssert("still at lounge", () => loungeScreen.IsCurrentScreen()); AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] @@ -120,15 +115,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); - AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); - AddAssert("room join password correct", () => lastJoinedPassword == "password"); + AddUntilStep("room joined", () => MultiplayerClient.RoomJoined); } [Test] @@ -136,21 +130,27 @@ namespace osu.Game.Tests.Visual.Multiplayer { DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("attempt join room", () => InputManager.Key(Key.Enter)); AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); AddStep("press enter", () => InputManager.Key(Key.Enter)); - AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); - AddAssert("room join password correct", () => lastJoinedPassword == "password"); + AddAssert("room joined", () => MultiplayerClient.RoomJoined); } - private void onRoomJoined(Room room, string? password) + private void createRooms(params Room[] rooms) { - lastJoinedRoom = room; - lastJoinedPassword = password; + AddStep("create rooms", () => + { + foreach (var room in rooms) + API.Queue(new CreateRoomRequest(room)); + }); + + AddStep("refresh lounge", () => loungeScreen.RefreshRooms()); } + + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new MultiplayerTestSceneDependencies(); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index edeb1708e0..c2d3b17ccb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; namespace osu.Game.Tests.Visual.Multiplayer @@ -29,10 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, Height = 50, - Child = new MultiplayerMatchFooter - { - SelectedItem = new Bindable() - } + Child = new MultiplayerMatchFooter() } }; }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 2a5f16d091..9c85bdd57a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -39,6 +39,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestMultiplayerMatchSongSelect songSelect = null!; private Live importedBeatmapSet = null!; + private Room room = null!; [Resolved] private OsuConfigManager configManager { get; set; } = null!; @@ -46,28 +47,38 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray()))!; - Add(detachedBeatmapStore); + Add(beatmapStore); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); } private void setUp() { - AddStep("reset", () => + AddStep("create song select", () => { Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); SelectedMods.SetDefault(); + + LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(room)); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value!))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } @@ -137,8 +148,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create song select", () => { - SelectedRoom.Value!.Playlist.Single().RulesetID = 2; - songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value, SelectedRoom.Value.Playlist.Single()); + room.Playlist.Single().RulesetID = 2; + songSelect = new TestMultiplayerMatchSongSelect(room, room.Playlist.Single()); songSelect.OnLoadComplete += _ => Ruleset.Value = new TaikoRuleset().RulesetInfo; LoadScreen(songSelect); }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 8ea52f8099..e5e4921a17 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -30,6 +30,7 @@ using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; @@ -42,11 +43,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiplayerMatchSubScreen screen = null!; private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; - - public TestSceneMultiplayerMatchSubScreen() - : base(false) - { - } + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -65,8 +62,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("load match", () => { - SelectedRoom.Value = new Room { Name = "Test Room" }; - LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value!)); + room = new Room { Name = "Test Room" }; + LoadScreen(screen = new TestMultiplayerMatchSubScreen(room)); }); AddUntilStep("wait for load", () => screen.IsCurrentScreen()); @@ -77,7 +74,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -96,7 +93,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new TaikoRuleset().RulesetInfo).BeatmapInfo) { @@ -121,7 +118,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -138,7 +135,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) { @@ -169,7 +166,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -198,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -222,7 +219,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with no allowed mods", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -245,7 +242,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add two playlist items", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) { @@ -271,7 +268,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("last playlist item selected", () => { - var lastItem = this.ChildrenOfType().Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID); + var lastItem = this.ChildrenOfType() + .Single() + .ChildrenOfType() + .Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID); return lastItem.IsSelectedItem; }); } @@ -281,7 +281,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -313,6 +313,29 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); } + [Test] + public void TestChangeSettingsButtonVisibleForHost() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + ClickButtonWhenEnabled(); + + AddUntilStep("wait for join", () => RoomJoined); + + AddUntilStep("button visible", () => this.ChildrenOfType().Single().ChangeSettingsButton?.Alpha, () => Is.GreaterThan(0)); + AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID })); + AddStep("make other user host", () => MultiplayerClient.TransferHost(PLAYER_1_ID)); + AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton?.Alpha, () => Is.EqualTo(0)); + } + private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen { [Resolved(canBeNull: true)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 95ae4c5e80..ed3fd4a6f8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -12,11 +12,14 @@ using osu.Framework.Utils; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Users; using osuTK; @@ -25,9 +28,12 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { - [SetUpSteps] - public void SetupSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); createNewParticipantsList(); } @@ -198,7 +204,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); - AddUntilStep("kick buttons not visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 0); + AddUntilStep("kick buttons not visible", () => !this.ChildrenOfType().Any(d => d.IsPresent)); AddStep("make local user host again", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id)); @@ -308,6 +314,33 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set state: locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable())); } + [Test] + public void TestUserWithStyle() + { + AddStep("add users", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = 0, + Username = "User 0", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + MultiplayerClient.ChangeUserStyle(0, 259, 2); + }); + + AddStep("set beatmap locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable())); + AddStep("change user style to beatmap: 258, ruleset: 1", () => MultiplayerClient.ChangeUserStyle(0, 258, 1)); + AddStep("change user style to beatmap: null, ruleset: null", () => MultiplayerClient.ChangeUserStyle(0, null, null)); + } + [Test] public void TestModOverlap() { @@ -366,6 +399,40 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [Test] + public void TestModsAndRuleset() + { + AddStep("add another user", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = 0, + Username = "User 0", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable()); + }); + + AddStep("set user styles", () => + { + MultiplayerClient.ChangeUserStyle(API.LocalUser.Value.OnlineID, 259, 1); + MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, + [new APIMod(new TaikoModConstantSpeed()), new APIMod(new TaikoModHidden()), new APIMod(new TaikoModFlashlight()), new APIMod(new TaikoModHardRock())]); + + MultiplayerClient.ChangeUserStyle(0, 259, 2); + MultiplayerClient.ChangeUserMods(0, + [new APIMod(new CatchModFloatingFruits()), new APIMod(new CatchModHidden()), new APIMod(new CatchModMirror())]); + }); + } + private void createNewParticipantsList() { ParticipantsList? participantsList = null; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs index 94dd114c32..99bec1e714 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs @@ -22,6 +22,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerPlayer player = null!; + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + } + [Test] public void TestGameplay() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 36f5bba384..1affa08813 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; private BeatmapInfo importedBeatmap = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -46,9 +47,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); + AddStep("create list", () => { - Child = list = new MultiplayerPlaylist(SelectedRoom.Value!) + Child = list = new MultiplayerPlaylist(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -127,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer addItemStep(); AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); - AddStep("leave room", () => RoomManager.PartRoom()); + AddStep("leave room", () => MultiplayerClient.LeaveRoom()); AddUntilStep("wait for room part", () => !RoomJoined); AddUntilStep("item 0 not in lists", () => !inHistoryList(0) && !inQueueList(0)); @@ -148,7 +153,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); assertQueueTabCount(2); - AddStep("leave room", () => RoomManager.PartRoom()); + AddStep("leave room", () => MultiplayerClient.LeaveRoom()); AddUntilStep("wait for room part", () => !RoomJoined); assertQueueTabCount(0); } @@ -157,12 +162,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestJoinRoomWithMixedItemsAddedInCorrectLists() { - AddStep("leave room", () => RoomManager.PartRoom()); + AddStep("leave room", () => MultiplayerClient.LeaveRoom()); AddUntilStep("wait for room part", () => !RoomJoined); AddStep("join room with items", () => { - RoomManager.CreateRoom(new Room + API.Queue(new CreateRoomRequest(new Room { Name = "test name", Playlist = @@ -177,7 +182,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Expired = true } ] - }); + })); }); AddUntilStep("wait for room join", () => RoomJoined); @@ -215,7 +220,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () => { - MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) + MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) { Expired = expired, PlayedAt = DateTimeOffset.Now @@ -266,7 +271,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertQueueTabCount(int count) { - string queueTabText = count > 0 ? $"Queue ({count})" : "Queue"; + string queueTabText = count > 0 ? $"Up next ({count})" : "Up next"; AddUntilStep($"Queue tab shows \"{queueTabText}\"", () => { return this.ChildrenOfType.OsuTabItem>() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index 3ef2e4ecf4..7283e3a1fe 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -29,6 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; private BeatmapInfo importedBeatmap = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -42,9 +43,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); + AddStep("create playlist", () => { - Child = playlist = new MultiplayerQueueList(SelectedRoom.Value!) + Child = playlist = new MultiplayerQueueList(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -133,7 +138,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add playlist item", () => { - MultiplayerPlaylistItem item = TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); + MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); MultiplayerClient.AddUserPlaylistItem(userId(), item).WaitSafely(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 1429f86164..ff5436a87d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -5,7 +5,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -28,6 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerSpectateButton spectateButton = null!; private MatchStartControl startControl = null!; + private Room room = null!; private BeatmapSetInfo importedSet = null!; private BeatmapManager beatmaps = null!; @@ -46,11 +46,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); + AddStep("create button", () => { - PlaylistItem item = SelectedRoom.Value!.Playlist.First(); - - AvailabilityTracker.SelectedItem.Value = item; + AvailabilityTracker.SelectedItem.Value = room.Playlist.First(); importedSet = beatmaps.GetAllUsableBeatmapSets().First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); @@ -68,15 +70,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(200, 50), - SelectedItem = new Bindable(item) + Size = new Vector2(200, 50) }, startControl = new MatchStartControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(200, 50), - SelectedItem = new Bindable(item) + Size = new Vector2(200, 50) } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index fa1909254a..7c73fb8321 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -27,22 +27,23 @@ namespace osu.Game.Tests.Visual.Multiplayer { private BeatmapManager manager = null!; private TestPlaylistsSongSelect songSelect = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); var beatmapSet = TestResources.CreateTestBeatmapSetInfo(); manager.Import(beatmapSet); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() @@ -51,13 +52,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("reset", () => { - SelectedRoom.Value = new Room(); + room = new Room(); Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); SelectedMods.Value = Array.Empty(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(SelectedRoom.Value!))); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(room))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } @@ -65,14 +66,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestItemAddedIfEmptyOnStart() { AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestItemAddedWhenCreateNewItemClicked() { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] @@ -80,7 +81,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] @@ -88,7 +89,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("playlist has 2 items", () => SelectedRoom.Value!.Playlist.Count == 2); + AddAssert("playlist has 2 items", () => room.Playlist.Count == 2); } [Test] @@ -96,10 +97,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("rearrange", () => SelectedRoom.Value!.Playlist = SelectedRoom.Value!.Playlist.Skip(1).Append(SelectedRoom.Value!.Playlist[0]).ToArray()); + AddStep("rearrange", () => room.Playlist = room.Playlist.Skip(1).Append(room.Playlist[0]).ToArray()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("new item has id 2", () => SelectedRoom.Value!.Playlist.Last().ID == 2); + AddAssert("new item has id 2", () => room.Playlist.Last().ID == 2); } /// @@ -115,13 +116,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item 1 has rate 1.5", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)room.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, mod.SpeedChange.Value); }); AddAssert("item 2 has rate 2", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)room.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(2, mod.SpeedChange.Value); }); } @@ -147,7 +148,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); AddAssert("item has rate 1.5", () => { - var m = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var m = (OsuModDoubleTime)room.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, m.SpeedChange.Value); }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs similarity index 56% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs rename to osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs index 797b69ec72..27c5758afa 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -17,11 +18,11 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneLoungeRoomsContainer : OnlinePlayTestScene + public partial class TestSceneRoomListing : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private RoomsContainer container = null!; + private BindableList rooms = null!; + private IBindable selectedRoom = null!; + private RoomListing container = null!; public override void SetUpSteps() { @@ -29,17 +30,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create container", () => { + rooms = new BindableList(); + selectedRoom = new Bindable(); + Child = new PopoverContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - - Child = container = new RoomsContainer + Child = container = new RoomListing { - SelectedRoom = { BindTarget = SelectedRoom } + RelativeSizeAxes = Axes.Both, + Rooms = { BindTarget = rooms }, + SelectedRoom = { BindTarget = selectedRoom } } }; }); @@ -48,57 +52,58 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestBasicListChanges() { - AddStep("add rooms", () => RoomManager.AddRooms(5, withSpotlightRooms: true)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(5, withSpotlightRooms: true))); - AddAssert("has 5 rooms", () => container.Rooms.Count == 5); + AddAssert("has 5 rooms", () => container.DrawableRooms.Count == 5); - AddAssert("all spotlights at top", () => container.Rooms + AddAssert("all spotlights at top", () => container.DrawableRooms .SkipWhile(r => r.Room.Category == RoomCategory.Spotlight) .All(r => r.Room.Category == RoomCategory.Normal)); - AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.First(r => r.RoomID == 0))); - AddAssert("has 4 rooms", () => container.Rooms.Count == 4); - AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID != 0)); + AddStep("remove first room", () => rooms.RemoveAt(0)); + AddAssert("has 4 rooms", () => container.DrawableRooms.Count == 4); + AddAssert("first room removed", () => container.DrawableRooms.All(r => r.Room.RoomID != 0)); - AddStep("select first room", () => container.Rooms.First().TriggerClick()); - AddAssert("first spotlight selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); + AddStep("select first room", () => container.DrawableRooms.First().TriggerClick()); + AddAssert("first spotlight selected", () => checkRoomSelected(rooms.First(r => r.Category == RoomCategory.Spotlight))); - AddStep("remove last room", () => RoomManager.RemoveRoom(RoomManager.Rooms.MinBy(r => r.RoomID)!)); - AddAssert("first spotlight still selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); + AddStep("remove last room", () => rooms.RemoveAt(rooms.Count - 1)); + AddAssert("first spotlight still selected", () => checkRoomSelected(rooms.First(r => r.Category == RoomCategory.Spotlight))); - AddStep("remove spotlight room", () => RoomManager.RemoveRoom(RoomManager.Rooms.Single(r => r.Category == RoomCategory.Spotlight))); + AddStep("remove spotlight room", () => rooms.RemoveAll(r => r.Category == RoomCategory.Spotlight)); AddAssert("selection vacated", () => checkRoomSelected(null)); } [Test] public void TestKeyboardNavigation() { - AddStep("add rooms", () => RoomManager.AddRooms(3)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3))); AddAssert("no selection", () => checkRoomSelected(null)); press(Key.Down); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); press(Key.Up); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); press(Key.Down); press(Key.Down); - AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + AddAssert("last room selected", () => checkRoomSelected(container.DrawableRooms.Last().Room)); } [Test] public void TestKeyboardNavigationAfterOrderChange() { - AddStep("add rooms", () => RoomManager.AddRooms(3)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3))); AddStep("reorder rooms", () => { - var room = RoomManager.Rooms[1]; + var room = rooms[1]; + rooms.Remove(room); - RoomManager.RemoveRoom(room); - RoomManager.AddOrUpdateRoom(room); + room.RoomID += 3; + rooms.Add(room); }); AddAssert("no selection", () => checkRoomSelected(null)); @@ -116,12 +121,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestClickDeselection() { - AddStep("add room", () => RoomManager.AddRooms(1)); + AddStep("add room", () => rooms.AddRange(GenerateRooms(1))); AddAssert("no selection", () => checkRoomSelected(null)); press(Key.Down); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); AddStep("click away", () => InputManager.Click(MouseButton.Left)); AddAssert("no selection", () => checkRoomSelected(null)); @@ -135,34 +140,34 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestStringFiltering() { - AddStep("add rooms", () => RoomManager.AddRooms(4)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(4))); - AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + AddUntilStep("4 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 4); - AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = "1" }); + AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = rooms.First().Name }); - AddUntilStep("1 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 1); + AddUntilStep("1 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 1); AddStep("remove filter", () => container.Filter.Value = null); - AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + AddUntilStep("4 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 4); } [Test] public void TestRulesetFiltering() { - AddStep("add rooms", () => RoomManager.AddRooms(2, new OsuRuleset().RulesetInfo)); - AddStep("add rooms", () => RoomManager.AddRooms(3, new CatchRuleset().RulesetInfo)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(2, new OsuRuleset().RulesetInfo))); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3, new CatchRuleset().RulesetInfo))); // Todo: What even is this case...? AddStep("set empty filter criteria", () => container.Filter.Value = new FilterCriteria()); - AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); + AddUntilStep("5 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 5); AddStep("filter osu! rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo }); - AddUntilStep("2 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + AddUntilStep("2 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2); AddStep("filter catch rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo }); - AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); + AddUntilStep("3 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 3); } [Test] @@ -170,30 +175,30 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add rooms", () => { - RoomManager.AddRooms(1, withPassword: true); - RoomManager.AddRooms(1, withPassword: false); + rooms.AddRange(GenerateRooms(1, withPassword: true)); + rooms.AddRange(GenerateRooms(1, withPassword: false)); }); AddStep("apply default filter", () => container.Filter.SetDefault()); - AddUntilStep("both rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + AddUntilStep("both rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2); AddStep("filter public rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Public }); - AddUntilStep("private room hidden", () => container.Rooms.All(r => !r.Room.HasPassword)); + AddUntilStep("private room hidden", () => container.DrawableRooms.All(r => !r.Room.HasPassword)); AddStep("filter private rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Private }); - AddUntilStep("public room hidden", () => container.Rooms.All(r => r.Room.HasPassword)); + AddUntilStep("public room hidden", () => container.DrawableRooms.All(r => r.Room.HasPassword)); } [Test] public void TestPasswordProtectedRooms() { - AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3, withPassword: true))); } - private bool checkRoomSelected(Room? room) => SelectedRoom.Value == room; + private bool checkRoomSelected(Room? room) => selectedRoom.Value == room; private Room? getRoomInFlow(int index) => (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs index 88afef7de2..ecdbfc411a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs @@ -3,29 +3,71 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Resources; +using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene + public partial class TestSceneStarRatingRangeDisplay : OsuTestScene { - public override void SetUpSteps() + private readonly Room room = new Room(); + + protected override void LoadComplete() { - base.SetUpSteps(); + base.LoadComplete(); - AddStep("create display", () => + Child = new FillFlowContainer { - SelectedRoom.Value = new Room(); - - Child = new StarRatingRangeDisplay(SelectedRoom.Value) + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - }); + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(5), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(2), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(1), + }, + } + }; } [Test] @@ -33,10 +75,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ - new PlaylistItem(new BeatmapInfo { StarRating = min }), - new PlaylistItem(new BeatmapInfo { StarRating = max }), + new PlaylistItem(new BeatmapInfo { StarRating = min }) { ID = TestResources.GetNextTestID() }, + new PlaylistItem(new BeatmapInfo { StarRating = max }) { ID = TestResources.GetNextTestID() }, ]; }); } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index d76e0290ef..ee5b1797ed 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -165,7 +165,6 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - [Solo] public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() { prepareBeatmap(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs index 83430b5665..be9dc387f2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Navigation }); AddStep("create IPC sender channels", () => { - ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { IPCPort = OsuGame.IPC_PORT }); + ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { IPCPipeName = OsuGame.IPC_PIPE_NAME }); osuSchemeLinkIPCSender = new OsuSchemeLinkIPCChannel(ipcSenderHost); archiveImportIPCSender = new ArchiveImportIPCChannel(ipcSenderHost); }); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs index a89f5fb647..26a37fa211 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; using osu.Framework.Extensions; using osu.Game.Configuration; @@ -58,7 +56,11 @@ namespace osu.Game.Tests.Visual.Navigation // First scroll makes volume controls appear, second adjusts volume. AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 10); - AddAssert("Volume is still zero", () => Game.Audio.Volume.Value == 0); + AddAssert("Volume is still zero", () => Game.Audio.Volume.Value, () => Is.Zero); + + AddStep("Pause", () => InputManager.PressKey(Key.Escape)); + AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 10); + AddAssert("Volume is above zero", () => Game.Audio.Volume.Value > 0); } [Test] @@ -80,8 +82,8 @@ namespace osu.Game.Tests.Visual.Navigation private void loadToPlayerNonBreakTime() { - Player player = null; - Screens.Select.SongSelect songSelect = null; + Player? player = null; + Screens.Select.SongSelect songSelect = null!; PushAndConfirm(() => songSelect = new TestSceneScreenNavigation.TestPlaySongSelect()); AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); @@ -95,7 +97,7 @@ namespace osu.Game.Tests.Visual.Navigation return (player = Game.ScreenStack.CurrentScreen as Player) != null; }); - AddUntilStep("wait for play time active", () => !player.IsBreakTime.Value); + AddUntilStep("wait for play time active", () => player!.IsBreakTime.Value, () => Is.False); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 5646649d33..8c4fcc461c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -41,16 +42,17 @@ using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Select.Options; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Resources; using osu.Game.Utils; using osuTK; using osuTK.Input; -using SharpCompress; namespace osu.Game.Tests.Visual.Navigation { @@ -201,6 +203,38 @@ namespace osu.Game.Tests.Visual.Navigation TextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); } + [Test] + public void TestSongSelectRandomRewindButton() + { + Guid? originalSelection = null; + TestPlaySongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("Add two beatmaps", () => + { + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + }); + + AddUntilStep("wait for selected", () => + { + originalSelection = Game.Beatmap.Value.BeatmapInfo.ID; + return !Game.Beatmap.IsDefault; + }); + + AddStep("hit random", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for selection changed", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.Not.EqualTo(originalSelection)); + + AddStep("hit random rewind", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("wait for selection reverted", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.EqualTo(originalSelection)); + } + [Test] public void TestSongSelectScrollHandling() { @@ -317,6 +351,92 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for song select", () => songSelect.IsCurrentScreen()); } + [Test] + public void TestOffsetAdjustDuringPause() + { + Player player = null; + + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() }); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + checkOffset(0); + + AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + AddStep("pause", () => player.ChildrenOfType().First().Stop()); + AddUntilStep("wait for pause", () => player.ChildrenOfType().First().IsPaused.Value, () => Is.True); + AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + void checkOffset(double offset) + { + AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Current.Value, + () => Is.EqualTo(offset)); + AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset, + () => Is.EqualTo(offset)); + } + } + + [Test] + public void TestOffsetAdjustDuringGameplay() + { + Player player = null; + + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() }); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + checkOffset(0); + + AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + AddStep("seek beyond 10 seconds", () => player.ChildrenOfType().First().Seek(10500)); + AddUntilStep("wait for seek", () => player.ChildrenOfType().First().CurrentTime, () => Is.GreaterThan(10600)); + AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + void checkOffset(double offset) + { + AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Current.Value, + () => Is.EqualTo(offset)); + AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset, + () => Is.EqualTo(offset)); + } + } + [Test] public void TestRetryCountIncrements() { @@ -355,18 +475,18 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - public void TestLastScoreNullAfterExitingPlayer() + public void TestLastScoreNotNullAfterExitingPlayer() { - AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + AddUntilStep("last play null", getLastPlay, () => Is.Null); var getOriginalPlayer = playToCompletion(); AddStep("attempt to retry", () => getOriginalPlayer().ChildrenOfType().First().Action()); - AddUntilStep("wait for last play matches player", getLastPlay, () => Is.EqualTo(getOriginalPlayer().Score.ScoreInfo)); + AddUntilStep("last play matches player", getLastPlay, () => Is.EqualTo(getOriginalPlayer().Score.ScoreInfo)); AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != getOriginalPlayer() && Game.ScreenStack.CurrentScreen is Player); AddStep("exit player", () => (Game.ScreenStack.CurrentScreen as Player)?.Exit()); - AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + AddUntilStep("last play not null", getLastPlay, () => Is.Not.Null); ScoreInfo getLastPlay() => Game.Dependencies.Get().Get(Static.LastLocalUserScore); } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 5267a57a05..622c85774a 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -5,6 +5,7 @@ using System; using System.Linq; +using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -23,6 +24,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps.IO; @@ -101,6 +103,77 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); } + [Test] + public void TestMutateProtectedSkinFromMainMenu_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + openSkinEditor(); + AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Z); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + + [Test] + public void TestMutateProtectedSkinFromPlayer_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + advanceToSongSelect(); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("enable NF", () => Game.SelectedMods.Value = new[] { new OsuModNoFail() }); + AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + openSkinEditor(); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Z); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + [Test] public void TestComponentsDeselectedOnSkinEditorHide() { @@ -212,6 +285,33 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("no mod selected", () => !((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Any()); } + [Test] + public void TestGameplaySettingsDoesNotExpandWhenSkinOverlayPresent() + { + advanceToSongSelect(); + openSkinEditor(); + AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); + + AddUntilStep("wait for settings", () => getPlayerSettingsOverlay() != null); + AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + AddStep("move cursor to right of screen", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight)); + AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + toggleSkinEditor(); + + AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(1))); + AddUntilStep("settings visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.GreaterThan(0)); + + AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0))); + AddUntilStep("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + PlayerSettingsOverlay getPlayerSettingsOverlay() => ((Player)Game.ScreenStack.CurrentScreen).ChildrenOfType().SingleOrDefault(); + } + [Test] public void TestCinemaModRemovedOnEnteringGameplay() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 325cb9e0cb..822e5f26bd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -289,12 +289,37 @@ namespace osu.Game.Tests.Visual.Online { InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(0)); }); - AddAssert("guest mapper information not shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().All(s => s.Text != "BanchoBot")); + AddAssert("guest mapper information not shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().All(s => s.Text != "BanchoBot0")); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddAssert("guest mapper information shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().Any(s => s.Text == "BanchoBot0")); + } + + [Test] + public void TestBeatmapsetWithALotGuestOwner() + { + AddStep("show map with 2 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(2))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 3 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(3))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 10 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(10))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 20 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(20))); AddStep("move mouse to guest difficulty", () => { InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); }); - AddAssert("guest mapper information shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().Any(s => s.Text == "BanchoBot")); } private APIBeatmapSet createManyDifficultiesBeatmapSet() @@ -336,22 +361,31 @@ namespace osu.Game.Tests.Visual.Online return beatmapSet; } - private APIBeatmapSet createBeatmapSetWithGuestDifficulty() + private APIBeatmapSet createBeatmapSetWithGuestDifficulty(int guestCount = 1) { var set = getBeatmapSet(); var beatmaps = new List(); + var beatmapOwners = new List(); + var ownersAPIUser = new List(); - var guestUser = new APIUser + for (int i = 0; i < guestCount; i++) { - Username = @"BanchoBot", - Id = 3, - }; + var guestUser = new APIUser + { + Username = @$"BanchoBot{i}", + Id = i + 3, + }; - set.RelatedUsers = new[] - { - set.Author, guestUser - }; + beatmapOwners.Add(new APIBeatmap.BeatmapOwner + { + Username = @$"BanchoBot{i}", + Id = i + 3, + }); + ownersAPIUser.Add(guestUser); + } + + set.RelatedUsers = new[] { set.Author }.Concat(ownersAPIUser).ToArray(); beatmaps.Add(new APIBeatmap { @@ -366,7 +400,7 @@ namespace osu.Game.Tests.Visual.Online Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), }, - Status = BeatmapOnlineStatus.Graveyard + Status = BeatmapOnlineStatus.Graveyard, }); beatmaps.Add(new APIBeatmap @@ -382,7 +416,8 @@ namespace osu.Game.Tests.Visual.Online Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), }, - Status = BeatmapOnlineStatus.Graveyard + Status = BeatmapOnlineStatus.Graveyard, + BeatmapOwners = beatmapOwners.ToArray(), }); set.Beatmaps = beatmaps.ToArray(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs index 5f77e084da..364240502a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs @@ -115,6 +115,8 @@ namespace osu.Game.Tests.Visual.Online channelList.AddChannel(createRandomPrivateChannel()); }); + AddStep("Add Team Channel", () => channelList.AddChannel(createRandomTeamChannel())); + AddStep("Add Announce Channels", () => { for (int i = 0; i < 2; i++) @@ -189,5 +191,16 @@ namespace osu.Game.Tests.Visual.Online Id = id, }; } + + private Channel createRandomTeamChannel() + { + int id = TestResources.GetNextTestID(); + return new Channel + { + Name = $"Team {id}", + Type = ChannelType.Team, + Id = id, + }; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs index b696c5d8ca..a1d0d40811 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs @@ -65,35 +65,38 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestBasicDisplay() { - AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence()); + IDisposable token = null!; + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); - AddStep("User began playing", () => spectatorClient.SendStartPlay(streamingUser.Id, 0)); + AddStep("User began playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); - AddStep("User finished playing", () => spectatorClient.SendEndPlay(streamingUser.Id)); + AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); AddUntilStep("Panel no longer present", () => !currentlyOnline.ChildrenOfType().Any()); - AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence()); + AddStep("End watching user presence", () => token.Dispose()); } [Test] public void TestUserWasPlayingBeforeWatchingUserPresence() { - AddStep("User began playing", () => spectatorClient.SendStartPlay(streamingUser.Id, 0)); - AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence()); - AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); + IDisposable token = null!; + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); + AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); - AddStep("User finished playing", () => spectatorClient.SendEndPlay(streamingUser.Id)); + AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); - AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence()); + AddStep("End watching user presence", () => token.Dispose()); } internal partial class TestUserLookupCache : UserLookupCache diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 7925b252b6..25611cf8d5 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -5,46 +5,227 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; 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.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Tests.Visual.Metadata; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public partial class TestSceneFriendDisplay : OsuTestScene { - protected override bool UseOnlineAPI => true; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - private FriendDisplay display; + private TestMetadataClient metadataClient; [SetUp] public void Setup() => Schedule(() => { - Child = new BasicScrollContainer + Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Child = display = new FriendDisplay() + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FriendDisplay() + } + } }; }); [Test] - public void TestOffline() + public void TestAddAndRemoveFriends() { - AddStep("Populate with offline test users", () => display.Users = getUsers()); + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("remove one friend", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.RemoveAt(0); + }); + + waitForLoad(); + assertVisiblePanelCount(2); + + AddStep("add one friend", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.AddRange(getUsers().Take(1).Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); } [Test] - public void TestOnline() + public void TestChangeDisplayStyle() { - // No need to do anything, fetch is performed automatically. + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("set list style", () => this.ChildrenOfType().Single().DisplayStyle.Value = OverlayPanelDisplayStyle.List); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("set brick style", () => this.ChildrenOfType().Single().DisplayStyle.Value = OverlayPanelDisplayStyle.Brick); + + waitForLoad(); + assertVisiblePanelCount(3); + } + + [Test] + public void TestOnlinePresence() + { + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("change to online stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Online); + assertVisiblePanelCount(0); + + AddStep("bring a friend online", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.Friends[0].TargetID, new UserPresence { Status = UserStatus.Online }); + }); + + assertVisiblePanelCount(1); + + AddStep("change to offline stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Offline); + assertVisiblePanelCount(2); + + AddStep("bring a friend online", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.Friends[1].TargetID, new UserPresence { Status = UserStatus.Online }); + }); + + assertVisiblePanelCount(1); + + AddStep("change to online stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Online); + assertVisiblePanelCount(2); + + AddStep("take friend offline", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.Friends[1].TargetID, null); + }); + assertVisiblePanelCount(1); + + AddStep("change to all stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.All); + assertVisiblePanelCount(3); + } + + [Test] + public void TestLoadFriendsBeforeDisplay() + { + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + AddStep("load new display", () => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FriendDisplay() + } + } + }; + }); + + waitForLoad(); + assertVisiblePanelCount(3); + } + + private void waitForLoad() + => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + private void assertVisiblePanelCount(int expectedVisible) + where T : UserPanel + { + AddAssert($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType().Last().ChildrenOfType().All(p => p is T)); + AddAssert($"{expectedVisible} panels visible", () => this.ChildrenOfType().Last().ChildrenOfType().Count(p => p.IsPresent), + () => Is.EqualTo(expectedVisible)); } private List getUsers() => new List @@ -53,7 +234,7 @@ namespace osu.Game.Tests.Visual.Online { Username = "flyte", Id = 3103765, - IsOnline = true, + WasRecentlyOnline = true, Statistics = new UserStatistics { GlobalRank = 1111 }, CountryCode = CountryCode.JP, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" @@ -62,7 +243,7 @@ namespace osu.Game.Tests.Visual.Online { Username = "peppy", Id = 2, - IsOnline = false, + WasRecentlyOnline = false, Statistics = new UserStatistics { GlobalRank = 2222 }, CountryCode = CountryCode.AU, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", @@ -75,7 +256,7 @@ namespace osu.Game.Tests.Visual.Online Id = 8195163, CountryCode = CountryCode.BY, CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsOnline = false, + WasRecentlyOnline = false, LastVisit = DateTimeOffset.Now } }; diff --git a/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs new file mode 100644 index 0000000000..3d7ee137ba --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneImageProxying : OsuTestScene + { + [Test] + public void TestExternalImageLink() + { + MarkdownContainer markdown = null!; + + // use base MarkdownContainer as a method of directly attempting to load an image without proxying logic. + AddStep("load external without proxying", () => Child = markdown = new MarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddWaitStep("wait", 5); + AddAssert("image not loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture == null); + + AddStep("load external with proxying", () => Child = markdown = new OsuMarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddUntilStep("image loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture != null); + } + + [Test] + public void TestExternalImageLinkInComments() + { + MarkdownContainer markdown = null!; + + AddStep("load external with proxying", () => Child = markdown = new CommentMarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddUntilStep("image loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture != null); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs index 1e9b0317fb..56d03d4c7f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -8,7 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Online.API; +using osu.Game.Configuration; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; @@ -23,17 +23,23 @@ namespace osu.Game.Tests.Visual.Online [Cached(typeof(IChannelPostTarget))] private PostTarget postTarget { get; set; } - private DummyAPIAccess api => (DummyAPIAccess)API; + private SessionStatics session = null!; public TestSceneNowPlayingCommand() { Add(postTarget = new PostTarget()); } + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(session = new SessionStatics()); + } + [Test] public void TestGenericActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InLobby(new Room()))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -43,7 +49,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestEditActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.EditingBeatmap(new BeatmapInfo()))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -53,7 +59,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestPlayActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -64,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online [TestCase(false)] public void TestLinkPresence(bool hasOnlineId) { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InLobby(new Room()))); AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null) { @@ -82,7 +88,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestModPresence() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo))); AddStep("Add Hidden mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod() }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs index 4539eae25f..29272f7336 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs @@ -62,10 +62,7 @@ namespace osu.Game.Tests.Visual.Online CountryCode = countryCode, CoverUrl = cover, Colour = color ?? "000000", - Status = - { - Value = UserStatus.Online - }, + WasRecentlyOnline = true }; return new ClickableAvatar(user, showPanel) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 3f1d961588..896bda364a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -4,17 +4,18 @@ using System; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.Metadata; using osu.Game.Users; using osuTK; @@ -23,144 +24,142 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public partial class TestSceneUserPanel : OsuTestScene { - private readonly Bindable activity = new Bindable(); - private readonly Bindable status = new Bindable(); - - private UserGridPanel boundPanel1 = null!; - private TestUserListPanel boundPanel2 = null!; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - [Cached(typeof(LocalUserStatisticsProvider))] - private readonly TestUserStatisticsProvider statisticsProvider = new TestUserStatisticsProvider(); - [Resolved] private IRulesetStore rulesetStore { get; set; } = null!; + private TestUserStatisticsProvider statisticsProvider = null!; + private TestMetadataClient metadataClient = null!; + private TestUserListPanel panel = null!; + [SetUp] public void SetUp() => Schedule(() => { - activity.Value = null; - status.Value = null; - - Remove(statisticsProvider, false); - Clear(); - Add(statisticsProvider); - - Add(new FillFlowContainer + Child = new DependencyProvidingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Spacing = new Vector2(10f), + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LocalUserStatisticsProvider), statisticsProvider = new TestUserStatisticsProvider()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], Children = new Drawable[] { - new UserBrickPanel(new APIUser + statisticsProvider, + metadataClient, + new FillFlowContainer { - Username = @"flyte", - Id = 3103765, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - }), - new UserBrickPanel(new APIUser - { - Username = @"peppy", - Id = 2, - Colour = "99EB47", - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - }), - new UserGridPanel(new APIUser - { - Username = @"flyte", - Id = 3103765, - CountryCode = CountryCode.JP, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - Status = { Value = UserStatus.Online } - }) { Width = 300 }, - boundPanel1 = new UserGridPanel(new APIUser - { - Username = @"peppy", - Id = 2, - CountryCode = CountryCode.AU, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsSupporter = true, - SupportLevel = 3, - }) { Width = 300 }, - boundPanel2 = new TestUserListPanel(new APIUser - { - Username = @"Evast", - Id = 8195163, - CountryCode = CountryCode.BY, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsOnline = false, - LastVisit = DateTimeOffset.Now - }), - new UserRankPanel(new APIUser - { - Username = @"flyte", - Id = 3103765, - CountryCode = CountryCode.JP, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 } - }) { Width = 300 }, - new UserRankPanel(new APIUser - { - Username = @"peppy", - Id = 2, - Colour = "99EB47", - CountryCode = CountryCode.AU, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } - }) { Width = 300 } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + new UserBrickPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + }), + new UserBrickPanel(new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + }), + new UserGridPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + WasRecentlyOnline = true + }) { Width = 300 }, + new UserGridPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsSupporter = true, + SupportLevel = 3, + }) { Width = 300 }, + panel = new TestUserListPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + LastVisit = DateTimeOffset.Now + }), + new UserRankPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 } + }) { Width = 300 }, + new UserRankPanel(new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + }) { Width = 300 }, + new UserGridPanel(API.LocalUser.Value) + { + Width = 300 + } + } + } } - }); + }; - boundPanel1.Status.BindTo(status); - boundPanel1.Activity.BindTo(activity); - - boundPanel2.Status.BindTo(status); - boundPanel2.Activity.BindTo(activity); + metadataClient.BeginWatchingUserPresence(); }); [Test] public void TestUserStatus() { - AddStep("online", () => status.Value = UserStatus.Online); - AddStep("do not disturb", () => status.Value = UserStatus.DoNotDisturb); - AddStep("offline", () => status.Value = UserStatus.Offline); - AddStep("null status", () => status.Value = null); + AddStep("online", () => setPresence(UserStatus.Online, null)); + AddStep("do not disturb", () => setPresence(UserStatus.DoNotDisturb, null)); + AddStep("offline", () => setPresence(UserStatus.Offline, null)); } [Test] public void TestUserActivity() { - AddStep("set online status", () => status.Value = UserStatus.Online); - - AddStep("idle", () => activity.Value = null); - AddStep("watching replay", () => activity.Value = new UserActivity.WatchingReplay(createScore(@"nats"))); - AddStep("spectating user", () => activity.Value = new UserActivity.SpectatingUser(createScore(@"mrekk"))); - AddStep("solo (osu!)", () => activity.Value = soloGameStatusForRuleset(0)); - AddStep("solo (osu!taiko)", () => activity.Value = soloGameStatusForRuleset(1)); - AddStep("solo (osu!catch)", () => activity.Value = soloGameStatusForRuleset(2)); - AddStep("solo (osu!mania)", () => activity.Value = soloGameStatusForRuleset(3)); - AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap()); - AddStep("editing beatmap", () => activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); - AddStep("modding beatmap", () => activity.Value = new UserActivity.ModdingBeatmap(new BeatmapInfo())); - AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(new BeatmapInfo())); + AddStep("idle", () => setPresence(UserStatus.Online, null)); + AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats")))); + AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk")))); + AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0))); + AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1))); + AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2))); + AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3))); + AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap())); + AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo()))); + AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo()))); + AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo()))); } [Test] public void TestUserActivityChange() { - AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent); - AddStep("set online status", () => status.Value = UserStatus.Online); - AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent); - AddStep("set choosing activity", () => activity.Value = new UserActivity.ChoosingBeatmap()); - AddStep("set offline status", () => status.Value = UserStatus.Offline); - AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent); - AddStep("set online status", () => status.Value = UserStatus.Online); - AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent); + AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent); + AddStep("set online status", () => setPresence(UserStatus.Online, null)); + AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent); + AddStep("set choosing activity", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap())); + AddStep("set offline status", () => setPresence(UserStatus.Offline, null)); + AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent); + AddStep("set online status", () => setPresence(UserStatus.Online, null)); + AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent); } [Test] @@ -185,6 +184,31 @@ namespace osu.Game.Tests.Visual.Online AddStep("set statistics to empty", () => statisticsProvider.UpdateStatistics(new UserStatistics(), Ruleset.Value)); } + [Test] + public void TestLocalUserActivity() + { + AddStep("idle", () => setPresence(UserStatus.Online, null, API.LocalUser.Value.OnlineID)); + AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats")), API.LocalUser.Value.OnlineID)); + AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk")), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3), API.LocalUser.Value.OnlineID)); + AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap(), API.LocalUser.Value.OnlineID)); + AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("set offline status", () => setPresence(UserStatus.Offline, null, API.LocalUser.Value.OnlineID)); + } + + private void setPresence(UserStatus status, UserActivity? activity, int? userId = null) + { + if (status == UserStatus.Offline) + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, null); + else + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, new UserPresence { Status = status, Activity = activity }); + } + private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(new BeatmapInfo(), rulesetStore.GetRuleset(rulesetId)!); private ScoreInfo createScore(string name) => new ScoreInfo(new TestBeatmap(Ruleset.Value).BeatmapInfo) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs index 0477d39193..2be9c1ab14 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; @@ -20,24 +21,16 @@ namespace osu.Game.Tests.Visual.Online public partial class TestSceneUserProfileDailyChallenge : OsuManualInputManagerTestScene { [Cached] - public readonly Bindable User = new Bindable(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo)); + private readonly Bindable userProfileData = new Bindable(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo)); [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - protected override void LoadComplete() + private DailyChallengeStatsDisplay display = null!; + + [SetUpSteps] + public void SetUpSteps() { - base.LoadComplete(); - - DailyChallengeStatsDisplay display = null!; - - AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v)); - AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v)); - AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v)); - AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); - AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); - AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); - AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v)); AddStep("create", () => { Clear(); @@ -51,16 +44,40 @@ namespace osu.Game.Tests.Visual.Online Anchor = Anchor.Centre, Origin = Anchor.Centre, Scale = new Vector2(1f), - User = { BindTarget = User }, + User = { BindTarget = userProfileData }, }); }); + + AddStep("set local user", () => update(s => s.UserID = API.LocalUser.Value.Id)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v)); + AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v)); + AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v)); + AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); + AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); + AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); + AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v)); + } + + [Test] + public void TestStates() + { + AddStep("played today", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date)); + AddStep("played yesterday", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date.AddDays(-1))); + AddStep("change to non-local user", () => update(s => s.UserID = API.LocalUser.Value.Id + 1000)); + AddStep("hover", () => InputManager.MoveMouseTo(display)); } private void update(Action change) { - change.Invoke(User.Value!.User.DailyChallengeStatistics); - User.Value = new UserProfileData(User.Value.User, User.Value.Ruleset); + change.Invoke(userProfileData.Value!.User.DailyChallengeStatistics); + userProfileData.Value = new UserProfileData(userProfileData.Value.User, userProfileData.Value.Ruleset); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 6167d1f760..193b356d71 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Visual.Online Id = 1001, Username = "IAmOnline", LastVisit = DateTimeOffset.Now, - IsOnline = true, + WasRecentlyOnline = true, }, new OsuRuleset().RulesetInfo)); AddStep("Show offline user", () => header.User.Value = new UserProfileData(new APIUser @@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.Online Id = 1002, Username = "IAmOffline", LastVisit = DateTimeOffset.Now.AddDays(-10), - IsOnline = false, + WasRecentlyOnline = false, }, new OsuRuleset().RulesetInfo)); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index d16ed46bd2..2972f69cba 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -346,6 +346,13 @@ namespace osu.Game.Tests.Visual.Online Twitter = "test_user", Discord = "test_user", Website = "https://google.com", + Team = new APITeam + { + Id = 1, + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/flag/1/wanglogo.jpg", + } }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 8909305602..e453a32652 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -67,19 +67,19 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestLink() { - AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/"); + AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/"); AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Main_page"); AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/FAQ"); AddStep("set './Writing''", () => markdownContainer.Text = "[wiki writing guidline](./Writing)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Writing"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Writing"); AddStep("set 'Formatting''", () => markdownContainer.Text = "[wiki formatting guidline](Formatting)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Formatting"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Formatting"); } [Test] diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..abfc5c4d0e --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Playlists; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneAddPlaylistToCollectionButton : OsuManualInputManagerTestScene + { + private BeatmapManager manager = null!; + private BeatmapSetInfo importedBeatmap = null!; + private Room room = null!; + private AddPlaylistToCollectionButton button = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + + Add(notificationOverlay); + } + + [Cached(typeof(INotificationOverlay))] + private NotificationOverlay notificationOverlay = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear realm", () => Realm.Realm.Write(() => Realm.Realm.RemoveAll())); + + AddStep("clear notifications", () => + { + foreach (var notification in notificationOverlay.AllNotifications) + notification.Close(runFlingAnimation: false); + }); + + importBeatmap(); + + setupRoom(); + + AddStep("create button", () => + { + Add(button = new AddPlaylistToCollectionButton(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 40), + }); + }); + } + + [Test] + public void TestButtonFlow() + { + AddStep("move mouse to button", () => InputManager.MoveMouseTo(button)); + + AddStep("click button", () => InputManager.Click(MouseButton.Left)); + + AddUntilStep("notification shown", () => notificationOverlay.AllNotifications.Any(n => n.Text.ToString().StartsWith("Created new collection", StringComparison.Ordinal))); + + AddUntilStep("realm is updated", () => Realm.Realm.All().FirstOrDefault(c => c.Name == room.Name) != null); + } + + private void importBeatmap() => AddStep("import beatmap", () => + { + var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); + + Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null); + + importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach(); + }); + + private void setupRoom() => AddStep("setup room", () => + { + room = new Room + { + Name = "my awesome room", + MaxAttempts = 5, + Host = API.LocalUser.Value + }; + room.RecentParticipants = [room.Host]; + room.EndDate = DateTimeOffset.Now.AddMinutes(5); + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 8c8dc8d69a..ceb3a32402 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -3,7 +3,6 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; @@ -17,25 +16,22 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private TestLoungeSubScreen loungeScreen = null!; + private PlaylistsLoungeSubScreen loungeScreen = null!; public override void SetUpSteps() { base.SetUpSteps(); - AddStep("push screen", () => LoadScreen(loungeScreen = new TestLoungeSubScreen())); - + AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen())); AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); } - private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + private RoomListing roomListing => loungeScreen.ChildrenOfType().First(); [Test] public void TestManyRooms() { - AddStep("add rooms", () => RoomManager.AddRooms(500)); + createRooms(GenerateRooms(500)); } [Test] @@ -43,59 +39,41 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("reset mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddStep("add rooms", () => RoomManager.AddRooms(30)); - AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); + createRooms(GenerateRooms(30)); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); - - AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomsContainer.Rooms[2])); + AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomListing.DrawableRooms[2])); AddStep("hold down", () => InputManager.PressButton(MouseButton.Left)); - AddStep("drag to top", () => InputManager.MoveMouseTo(roomsContainer.Rooms[0])); + AddStep("drag to top", () => InputManager.MoveMouseTo(roomListing.DrawableRooms[0])); AddAssert("first and second room masked", () - => !checkRoomVisible(roomsContainer.Rooms[0]) && - !checkRoomVisible(roomsContainer.Rooms[1])); + => !checkRoomVisible(roomListing.DrawableRooms[0]) && + !checkRoomVisible(roomListing.DrawableRooms[1])); } [Test] public void TestScrollSelectedIntoView() { - AddStep("add rooms", () => RoomManager.AddRooms(30)); - AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); + createRooms(GenerateRooms(30)); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); + AddStep("select last room", () => roomListing.DrawableRooms[^1].TriggerClick()); - AddStep("select last room", () => roomsContainer.Rooms[^1].TriggerClick()); - - AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms[0])); - AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms[^1])); - } - - [Test] - public void TestEnteringRoomTakesLeaseOnSelection() - { - AddStep("add rooms", () => RoomManager.AddRooms(1)); - - AddAssert("selected room is not disabled", () => !loungeScreen.SelectedRoom.Disabled); - - AddStep("select room", () => roomsContainer.Rooms[0].TriggerClick()); - AddAssert("selected room is non-null", () => loungeScreen.SelectedRoom.Value != null); - - AddStep("enter room", () => roomsContainer.Rooms[0].TriggerClick()); - - AddUntilStep("wait for match load", () => Stack.CurrentScreen is PlaylistsRoomSubScreen); - - AddAssert("selected room is non-null", () => loungeScreen.SelectedRoom.Value != null); - AddAssert("selected room is disabled", () => loungeScreen.SelectedRoom.Disabled); + AddUntilStep("first room is masked", () => !checkRoomVisible(roomListing.DrawableRooms[0])); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomListing.DrawableRooms[^1])); } private bool checkRoomVisible(DrawableRoom room) => loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad .Contains(room.ScreenSpaceDrawQuad.Centre); - private partial class TestLoungeSubScreen : PlaylistsLoungeSubScreen + private void createRooms(params Room[] rooms) { - public new Bindable SelectedRoom => base.SelectedRoom; + AddStep("create rooms", () => + { + foreach (var room in rooms) + API.Queue(new CreateRoomRequest(room)); + }); + + AddStep("refresh lounge", () => loungeScreen.RefreshRooms()); } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index 5868331451..c714c39e22 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -3,14 +3,13 @@ using System; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Visual.OnlinePlay; @@ -18,21 +17,38 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsMatchSettingsOverlay : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private TestRoomSettings settings = null!; - - protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + private Room room = null!; + private Func? handleRequest; public override void SetUpSteps() { base.SetUpSteps(); + AddStep("setup api", () => + { + handleRequest = null; + ((DummyAPIAccess)API).HandleRequest = req => + { + if (req is not CreateRoomRequest createReq || handleRequest == null) + return false; + + if (handleRequest(createReq.Room) is string errorText) + createReq.TriggerFailure(new APIException(errorText, null)); + else + { + var createdRoom = new APICreatedRoom(); + createdRoom.CopyFrom(createReq.Room); + createReq.TriggerSuccess(createdRoom); + } + + return true; + }; + }); + AddStep("create overlay", () => { - SelectedRoom.Value = new Room(); - - Child = settings = new TestRoomSettings(SelectedRoom.Value!) + Child = settings = new TestRoomSettings(room = new Room()) { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible } @@ -45,19 +61,19 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("clear name and beatmap", () => { - SelectedRoom.Value!.Name = ""; - SelectedRoom.Value!.Playlist = []; + room.Name = ""; + room.Playlist = []; }); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set name", () => SelectedRoom.Value!.Name = "Room name"); + AddStep("set name", () => room.Name = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]); + AddStep("set beatmap", () => room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); - AddStep("clear name", () => SelectedRoom.Value!.Name = ""); + AddStep("clear name", () => room.Name = ""); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); } @@ -73,12 +89,12 @@ namespace osu.Game.Tests.Visual.Playlists { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; - SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; + room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; - RoomManager.CreateRequested = r => + handleRequest = r => { createdRoom = r; - return string.Empty; + return null; }; }); @@ -98,22 +114,22 @@ namespace osu.Game.Tests.Visual.Playlists { var beatmap = CreateBeatmap(Ruleset.Value).BeatmapInfo; - SelectedRoom.Value!.Name = "Test Room"; - SelectedRoom.Value!.Playlist = [new PlaylistItem(beatmap)]; + room.Name = "Test Room"; + room.Playlist = [new PlaylistItem(beatmap)]; errorMessage = $"{not_found_prefix} {beatmap.OnlineID}"; - RoomManager.CreateRequested = _ => errorMessage; + handleRequest = _ => errorMessage; }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); - AddAssert("playlist item valid", () => SelectedRoom.Value!.Playlist[0].Valid.Value); + AddAssert("playlist item valid", () => room.Playlist[0].Valid.Value); AddStep("create room", () => settings.ApplyButton.Action.Invoke()); AddAssert("error displayed", () => settings.ErrorText.IsPresent); AddAssert("error has custom text", () => settings.ErrorText.Text != errorMessage); - AddAssert("playlist item marked invalid", () => !SelectedRoom.Value!.Playlist[0].Valid.Value); + AddAssert("playlist item marked invalid", () => !room.Playlist[0].Valid.Value); } [Test] @@ -125,10 +141,10 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("setup", () => { - SelectedRoom.Value!.Name = "Test Room"; - SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; + room.Name = "Test Room"; + room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; - RoomManager.CreateRequested = _ => failText; + handleRequest = _ => failText; }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); @@ -159,48 +175,5 @@ namespace osu.Game.Tests.Visual.Playlists { } } - - private class TestDependencies : OnlinePlayTestSceneDependencies - { - protected override IRoomManager CreateRoomManager() => new TestRoomManager(); - } - - protected class TestRoomManager : IRoomManager - { - public Func? CreateRequested; - - public event Action RoomsUpdated - { - add { } - remove { } - } - - public IBindable InitialRoomsReceived { get; } = new Bindable(true); - - public IBindableList Rooms => null!; - - public void AddOrUpdateRoom(Room room) => throw new NotImplementedException(); - - public void RemoveRoom(Room room) => throw new NotImplementedException(); - - public void ClearRooms() => throw new NotImplementedException(); - - public void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - { - if (CreateRequested == null) - return; - - string error = CreateRequested.Invoke(room); - - if (!string.IsNullOrEmpty(error)) - onError?.Invoke(error); - else - onSuccess?.Invoke(room); - } - - public void JoinRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) => throw new NotImplementedException(); - - public void PartRoom() => throw new NotImplementedException(); - } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index c60b208ffc..e1ec30d02a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -14,13 +14,15 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsParticipantsList : OnlinePlayTestScene { + private Room room = null!; + public override void SetUpSteps() { base.SetUpSteps(); - AddStep("create list", () => + AddStep("create room", () => { - SelectedRoom.Value = new Room + room = new Room { RoomID = 7, RecentParticipants = Enumerable.Range(0, 50).Select(_ => new APIUser @@ -38,7 +40,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Horizontal) + Child = new ParticipantsDisplay(room, Direction.Horizontal) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -52,7 +54,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Vertical) + Child = new ParticipantsDisplay(room, Direction.Vertical) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 33bd573617..6b73f1a5f4 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -7,9 +7,12 @@ using System.Linq; using System.Net; using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -32,6 +35,9 @@ namespace osu.Game.Tests.Visual.Playlists private const int scores_per_result = 10; private const int real_user_position = 200; + [Cached] + private readonly BeatmapLookupCache beatmapLookupCache = new BeatmapLookupCache(); + private ResultsScreen resultsScreen = null!; private int lowestScoreId; // Score ID of the lowest score in the list. @@ -41,6 +47,11 @@ namespace osu.Game.Tests.Visual.Playlists private int totalCount; private ScoreInfo userScore = null!; + public TestScenePlaylistsResultsScreen() + { + Add(beatmapLookupCache); + } + [SetUpSteps] public override void SetUpSteps() { @@ -58,9 +69,11 @@ namespace osu.Game.Tests.Visual.Playlists totalCount = 0; userScore = TestResources.CreateTestScoreInfo(); + userScore.OnlineID = 1; userScore.TotalScore = 0; userScore.Statistics = new Dictionary(); userScore.MaximumStatistics = new Dictionary(); + userScore.Position = real_user_position; // Beatmap is required to be an actual beatmap so the scores can get their scores correctly // calculated for standardised scoring, else the tests that rely on ordering will fall over. @@ -143,13 +156,13 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); } } @@ -167,26 +180,26 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("bind delayed handler with no scores", () => bindHandler(delayed: true, noScores: true)); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert("count not increased", () => this.ChildrenOfType().Count() == beforePanelCount); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); AddAssert("no placeholders shown", () => this.ChildrenOfType().Count(), () => Is.Zero); @@ -209,13 +222,13 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to left", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToStart(false)); - AddAssert("left loading spinner shown", () => + AddUntilStep("left loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("left loading spinner hidden", () => + AddUntilStep("left loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); } } @@ -229,7 +242,36 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => bindHandler(noScores: true)); createUserBestResults(); AddAssert("no scores visible", () => !resultsScreen.ChildrenOfType().Single().GetScorePanels().Any()); - AddAssert("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddUntilStep("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + } + + [Test] + public void TestFetchingAllTheWayToFirstNeverDisplaysNegativePosition() + { + AddStep("set user position", () => userScore.Position = 20); + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); + + createResultsWithScore(() => userScore); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(true)); + + for (int i = 0; i < 2; i++) + { + AddStep("simulate user falling down ranking", () => userScore.Position += 2); + AddStep("scroll to left", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToStart(false)); + + AddUntilStep("left loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Visible); + + waitForDisplay(); + + AddUntilStep("left loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); + } + + AddAssert("total count is 34", () => this.ChildrenOfType().Count(), () => Is.EqualTo(34)); + AddUntilStep("all panels have non-negative position", () => this.ChildrenOfType().All(p => p.ScorePosition.Value > 0)); } private void createResultsWithScore(Func getScore) @@ -279,6 +321,25 @@ namespace osu.Game.Tests.Visual.Playlists case IndexPlaylistScoresRequest: break; + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + + return true; + default: return false; } @@ -301,7 +362,7 @@ namespace osu.Game.Tests.Visual.Playlists if (userScore == null) triggerFail(s); else - triggerSuccess(s, createUserResponse(userScore)); + triggerSuccess(s, () => createUserResponse(userScore)); break; @@ -309,12 +370,12 @@ namespace osu.Game.Tests.Visual.Playlists if (userScore == null) triggerFail(u); else - triggerSuccess(u, createUserResponse(userScore)); + triggerSuccess(u, () => createUserResponse(userScore)); break; case IndexPlaylistScoresRequest i: - triggerSuccess(i, createIndexResponse(i, noScores)); + triggerSuccess(i, () => createIndexResponse(i, noScores)); break; } }, delay); @@ -322,11 +383,11 @@ namespace osu.Game.Tests.Visual.Playlists return true; }; - private void triggerSuccess(APIRequest req, T result) + private void triggerSuccess(APIRequest req, Func result) where T : class { requestComplete = true; - req.TriggerSuccess(result); + req.TriggerSuccess(result.Invoke()); } private void triggerFail(APIRequest req) @@ -337,33 +398,20 @@ namespace osu.Game.Tests.Visual.Playlists private MultiplayerScore createUserResponse(ScoreInfo userScore) { - var multiplayerUserScore = new MultiplayerScore - { - ID = highestScoreId, - Accuracy = userScore.Accuracy, - Passed = userScore.Passed, - Rank = userScore.Rank, - Position = real_user_position, - MaxCombo = userScore.MaxCombo, - User = userScore.User, - ScoresAround = new MultiplayerScoresAround - { - Higher = new MultiplayerScores(), - Lower = new MultiplayerScores() - } - }; + var multiplayerUserScore = createMultiplayerUserScore(userScore); totalCount++; for (int i = 1; i <= scores_per_result; i++) { - multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore + multiplayerUserScore.ScoresAround!.Lower!.Scores.Add(new MultiplayerScore { ID = getNextLowestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, @@ -372,13 +420,14 @@ namespace osu.Game.Tests.Visual.Playlists }, }); - multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore + multiplayerUserScore.ScoresAround!.Higher!.Scores.Add(new MultiplayerScore { ID = getNextHighestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, @@ -390,13 +439,33 @@ namespace osu.Game.Tests.Visual.Playlists totalCount += 2; } - addCursor(multiplayerUserScore.ScoresAround.Lower); - addCursor(multiplayerUserScore.ScoresAround.Higher); + addCursor(multiplayerUserScore.ScoresAround!.Lower!); + addCursor(multiplayerUserScore.ScoresAround!.Higher!); return multiplayerUserScore; } - private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req, bool noScores = false) + private MultiplayerScore createMultiplayerUserScore(ScoreInfo userScore) + { + return new MultiplayerScore + { + ID = highestScoreId, + Accuracy = userScore.Accuracy, + Passed = userScore.Passed, + Rank = userScore.Rank, + Position = userScore.Position, + MaxCombo = userScore.MaxCombo, + User = userScore.User, + BeatmapId = RNG.Next(0, 7), + ScoresAround = new MultiplayerScoresAround + { + Higher = new MultiplayerScores(), + Lower = new MultiplayerScores() + } + }; + } + + private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req, bool noScores) { var result = new IndexedMultiplayerScores(); @@ -404,15 +473,26 @@ namespace osu.Game.Tests.Visual.Playlists string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; + bool reachedEnd = false; + for (int i = 1; i <= scores_per_result; i++) { + int nextId = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(); + + if (userScore.OnlineID - nextId >= userScore.Position) + { + reachedEnd = true; + break; + } + result.Scores.Add(new MultiplayerScore { - ID = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(), + ID = nextId, Accuracy = 1, Passed = true, Rank = ScoreRank.X, MaxCombo = 1000, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, @@ -424,7 +504,10 @@ namespace osu.Game.Tests.Visual.Playlists totalCount++; } - addCursor(result); + if (!reachedEnd) + addCursor(result); + + result.UserScore = createMultiplayerUserScore(userScore); return result; } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index 0270840597..a748d61d44 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -35,6 +35,7 @@ namespace osu.Game.Tests.Visual.Playlists private BeatmapManager manager = null!; private TestPlaylistsRoomSubScreen match = null!; private BeatmapSetInfo importedBeatmap = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -47,11 +48,9 @@ namespace osu.Game.Tests.Visual.Playlists [SetUpSteps] public void SetupSteps() { - AddStep("set room", () => SelectedRoom.Value = new Room()); - importBeatmap(); - AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value!))); + AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(room = new Room()))); AddUntilStep("wait for load", () => match.IsCurrentScreen()); } @@ -119,7 +118,7 @@ namespace osu.Game.Tests.Visual.Playlists ]; }); - AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom.Value!.Playlist[0]); + AddAssert("first playlist item selected", () => match.SelectedItem.Value == room.Playlist[0]); } [Test] @@ -197,10 +196,9 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("match has correct beatmap", () => realHash == match.Beatmap.Value.BeatmapInfo.MD5Hash); } - private void setupAndCreateRoom(Action room) + private void setupAndCreateRoom(Action setupFunc) { - AddStep("setup room", () => room(SelectedRoom.Value!)); - + AddStep("setup room", () => setupFunc(room)); AddStep("click create button", () => { InputManager.MoveMouseTo(this.ChildrenOfType().Single()); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index 4306fc1e6a..e9c4b56e04 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -2,40 +2,615 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; using NUnit.Framework; -using osu.Framework.Screens; -using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsRoomSubScreen : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + BeatmapStore beatmapStore; + + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); + Dependencies.Cache(Realm); + + Add(beatmapStore); + + importedSet = beatmaps.Import(new BeatmapSetInfo + { + OnlineID = TestResources.GetNextTestID(), + Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), + DateAdded = DateTimeOffset.UtcNow, + Beatmaps = + { + new BeatmapInfo + { + OnlineID = 1, + DifficultyName = "Osu 1", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Ruleset = new OsuRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 2, + DifficultyName = "Osu 2", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Ruleset = new OsuRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 3, + DifficultyName = "Taiko 1", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Ruleset = new TaikoRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 4, + DifficultyName = "Taiko 2", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Ruleset = new TaikoRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + } + } + })!.PerformRead(s => s.Detach()); + } + + /// + /// Tests that the beatmap and ruleset are adjusted to follow the selected item. + /// [Test] - public void TestStatusUpdateOnEnter() + public void TestBeatmapAndRuleset_FollowSelection() { Room room = null!; - PlaylistsRoomSubScreen roomScreen = null!; - AddStep("create room", () => + AddStep("add room", () => { - RoomManager.AddRoom(room = new Room + room = new Room { - Name = @"Test Room", - Host = new APIUser { Username = @"Host" }, - Category = RoomCategory.Normal, - EndDate = DateTimeOffset.Now.AddMinutes(-1) - }); + RoomID = 1, + Playlist = + [ + // osu! beatmap + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + // osu! beatmap converted played in taiko + new PlaylistItem(importedSet.Beatmaps[1]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + } + ] + }; + + API.Perform(new CreateRoomRequest(room)); }); - AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); - AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); - AddAssert("status is still ended", () => roomScreen.Room.Status, Is.TypeOf); + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("select first item", () => screen.SelectedItem.Value = room.Playlist[0]); + AddUntilStep("first beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + AddUntilStep("osu ruleset selected", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("second beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1])); + AddUntilStep("taiko ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + } + + /// + /// Tests that the beatmap style is reset when the selected item is changed. + /// + [Test] + public void TestBeatmapStyle_Reset_OnSelection() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user beatmap style", () => screen.UserBeatmap.Value = importedSet.Beatmaps[1]); + AddUntilStep("user beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1])); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user beatmap style reset", () => screen.UserBeatmap.Value == null); + AddUntilStep("second beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + } + + /// + /// Tests that the ruleset style is reset when the selected item is changed and it's no longer valid. + /// + [Test] + public void TestRulesetStyle_Reset_OnSelection_IfNotValid() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset style", () => screen.UserRuleset.Value = new ManiaRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user ruleset style reset", () => screen.UserRuleset.Value == null); + AddUntilStep("second ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + } + + /// + /// Tests that the ruleset style is preserved when the selected item is changed and the ruleset is still valid. + /// + [Test] + public void TestRulesetStyle_Preserved_OnSelection_IfStillValid() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset style", () => screen.UserRuleset.Value = new ManiaRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user ruleset style preserved", () => screen.UserRuleset.Value!.Equals(new ManiaRuleset().RulesetInfo)); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + } + + /// + /// Tests that mod style is reset when the selected item is changed to another with an inconvertible ruleset. + /// No user style is assumed. + /// + [Test] + public void TestModsReset_OnSelection_DifferentRuleset_NoUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that mod style is preserved when the selected item is changed to another with the same ruleset. + /// No user style is assumed. + /// + [Test] + public void TestModsPreserved_OnSelection_SameRuleset_NoUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style preserved", () => screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods preserved", () => SelectedMods.Value.OfType().Any()); + } + + /// + /// Tests that mod style is reset when the selected item is changed to another with an inconvertible ruleset. + /// A user beatmap/ruleset style is assumed. + /// + [Test] + public void TestModsReset_OnSelection_DifferentRuleset_WithUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new CatchRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new CatchRuleset().RulesetInfo)); + AddStep("set user mods", () => screen.UserMods.Value = [new CatchModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that mod style is preserved when the selected item is changed to another with the same ruleset. + /// A user beatmap/ruleset style is assumed. + /// + [Test] + public void TestModsPreserved_OnSelection_SameRuleset_WithStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddStep("set user mods", () => screen.UserMods.Value = [new TaikoModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style preserved", () => screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods preserved", () => SelectedMods.Value.OfType().Any()); + } + + /// + /// Tests that the mod style is revalidated when the ruleset style is changed. + /// + [Test] + public void TestModsValidated_OnRulesetStyleChanged() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddUntilStep("user mods reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that the beatmap and ruleset style are reset when the selected item is changed to one without freestyle, + /// and that the mod selection is re-validated against the item's allowed mods. + /// + [Test] + public void TestUserStyle_Reset_OnFreestyleDisabled() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = [new APIMod(new OsuModDoubleTime())] + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + // Set beatmap + ruleset, reset by selecting second playlist item + AddStep("set user beatmap/ruleset style", () => + { + screen.UserBeatmap.Value = importedSet.Beatmaps[1]; + screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo; + }); + AddUntilStep("beatmap/ruleset set", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1]) && Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddStep("select second playlist item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user style reset", () => screen.UserBeatmap.Value == null && screen.UserRuleset.Value == null); + AddUntilStep("beatmap/ruleset set", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0]) && Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("select first playlist item", () => screen.SelectedItem.Value = room.Playlist[0]); + + // Set mods (DT+HR), validate by selecting second playlist item where only DT is allowed. + AddStep("set user mods style", () => screen.UserMods.Value = [new OsuModDoubleTime(), new OsuModHardRock()]); + AddUntilStep("mods set", () => SelectedMods.Value.OfType().Any() && SelectedMods.Value.OfType().Any()); + AddStep("select second playlist item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mods validated", () => screen.UserMods.Value.Count == 1 && screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods set", () => SelectedMods.Value.Count == 1 && SelectedMods.Value.OfType().Any()); + } + + private partial class TestPlaylistsScreen : OsuScreen + { + public TestPlaylistsScreen(PlaylistsRoomSubScreen screen) + { + OnlinePlaySubScreenStack stack; + + InternalChildren = new Drawable[] + { + stack = new OnlinePlaySubScreenStack + { + RelativeSizeAxes = Axes.Both + }, + new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => + { + if (stack.CurrentScreen is not PlaylistsRoomSubScreen) + stack.Exit(); + } + } + }; + + stack.Push(screen); + } + } + + private partial class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen + { + public new Bindable SelectedItem => base.SelectedItem; + public new Bindable UserBeatmap => base.UserBeatmap; + public new Bindable UserRuleset => base.UserRuleset; + public new Bindable> UserMods => base.UserMods; + + public TestPlaylistsRoomSubScreen(Room room) + : base(room) + { + } } } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 02a321d22f..eade5aaf5d 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(TestResources.CreateTestScoreInfo(beatmap)); }); - AddAssert("pp display faded out", () => + AddUntilStep("pp display faded out", () => { var ppDisplay = this.ChildrenOfType().Single(); return ppDisplay.Alpha == 0.5 && ppDisplay.TooltipText == ResultsScreenStrings.NoPPForUnrankedBeatmaps; @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(score); }); - AddAssert("pp display faded out", () => + AddUntilStep("pp display faded out", () => { var ppDisplay = this.ChildrenOfType().Single(); return ppDisplay.Alpha == 0.5 && ppDisplay.TooltipText == ResultsScreenStrings.NoPPForUnrankedMods; @@ -116,7 +116,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(score); }); - AddAssert("pp display faded out", () => this.ChildrenOfType().Single().Alpha == 1); + AddUntilStep("pp display faded out", () => this.ChildrenOfType().Single().Alpha == 1); } [Test] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs index e29063b5f8..fb18cc8a59 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online; using osu.Game.Scoring; @@ -12,7 +13,7 @@ namespace osu.Game.Tests.Visual.Ranking { public partial class TestSceneOverallRanking : OsuTestScene { - private OverallRanking overallRanking = null!; + private readonly Bindable statisticsUpdate = new Bindable(); [Test] public void TestUpdatePending() @@ -138,14 +139,19 @@ namespace osu.Game.Tests.Visual.Ranking new UserStatistics()); } - private void createDisplay() => AddStep("create display", () => Child = overallRanking = new OverallRanking + private void createDisplay() => AddStep("create display", () => { - Width = 400, - Anchor = Anchor.Centre, - Origin = Anchor.Centre + statisticsUpdate.Value = null; + Child = new OverallRanking(new ScoreInfo()) + { + Width = 400, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + DisplayedUpdate = { BindTarget = statisticsUpdate } + }; }); private void displayUpdate(UserStatistics before, UserStatistics after) => - AddStep("display update", () => overallRanking.StatisticsUpdate.Value = new ScoreBasedUserStatisticsUpdate(new ScoreInfo(), before, after)); + AddStep("display update", () => statisticsUpdate.Value = new ScoreBasedUserStatisticsUpdate(new ScoreInfo(), before, after)); } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index fca1d0f82a..4758b70526 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -4,7 +4,6 @@ #nullable disable using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; @@ -17,7 +16,6 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu; @@ -62,12 +60,6 @@ namespace osu.Game.Tests.Visual.Ranking if (beatmapInfo != null) Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); }); - - AddToggleStep("toggle legacy classic skin", v => - { - if (skins != null) - skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default; - }); } [SetUp] @@ -84,6 +76,16 @@ namespace osu.Game.Tests.Visual.Ranking })); } + [Test] + public void TestLegacySkin() + { + AddToggleStep("toggle legacy classic skin", v => + { + if (skins != null) + skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default; + }); + } + private int onlineScoreID = 1; [TestCase(1, ScoreRank.X, 0)] @@ -402,7 +404,7 @@ namespace osu.Game.Tests.Visual.Ranking : base(score) { AllowRetry = true; - ShowUserStatistics = true; + IsLocalPlay = true; } protected override void LoadComplete() @@ -412,21 +414,19 @@ namespace osu.Game.Tests.Visual.Ranking RetryOverlay = InternalChildren.OfType().SingleOrDefault(); } - protected override APIRequest FetchScores(Action> scoresCallback) + protected override Task FetchScores() { - var scores = new List(); + var scores = new ScoreInfo[20]; - for (int i = 0; i < 20; i++) + for (int i = 0; i < scores.Length; i++) { var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; score.HasOnlineReplay = true; - scores.Add(score); + scores[i] = score; } - scoresCallback.Invoke(scores); - - return null; + return Task.FromResult(scores); } } @@ -442,27 +442,25 @@ namespace osu.Game.Tests.Visual.Ranking this.fetchWaitTask = fetchWaitTask ?? Task.CompletedTask; } - protected override APIRequest FetchScores(Action> scoresCallback) + protected override Task FetchScores() { - Task.Run(async () => + return Task.Run(async () => { await fetchWaitTask; - var scores = new List(); + var scores = new ScoreInfo[20]; - for (int i = 0; i < 20; i++) + for (int i = 0; i < scores.Length; i++) { var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; - scores.Add(score); + scores[i] = score; } - scoresCallback?.Invoke(scores); - Schedule(() => FetchCompleted = true); - }); - return null; + return scores; + }); } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index c12b9d29bc..f82b32167c 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -18,8 +19,12 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Scoring; @@ -36,6 +41,8 @@ namespace osu.Game.Tests.Visual.Ranking { public partial class TestSceneStatisticsPanel : OsuTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + [Test] public void TestScoreWithPositionStatistics() { @@ -137,62 +144,136 @@ namespace osu.Game.Tests.Visual.Ranking { CachedDependencies = [(typeof(UserStatisticsWatcher), userStatisticsWatcher)], RelativeSizeAxes = Axes.Both, - Child = new UserStatisticsPanel(score) + Child = new StatisticsPanel { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, - Score = { Value = score, } + Score = { Value = score, }, + AchievedScore = score, } }); AddUntilStep("overall ranking present", () => this.ChildrenOfType().Any()); - AddUntilStep("loading spinner not visible", () => this.ChildrenOfType().All(l => l.State.Value == Visibility.Hidden)); + AddUntilStep("loading spinner not visible", + () => this.ChildrenOfType().Single() + .ChildrenOfType().All(l => l.State.Value == Visibility.Hidden)); + } + + [Test] + public void TestTagging() + { + var score = TestResources.CreateTestScoreInfo(); + + AddStep("set up network requests", () => + { + dummyAPI.HandleRequest = request => + { + switch (request) + { + case ListTagsRequest listTagsRequest: + { + Scheduler.AddDelayed(() => listTagsRequest.TriggerSuccess(new APITagCollection + { + Tags = + [ + new APITag { Id = 1, Name = "tech", Description = "Tests uncommon skills.", }, + new APITag { Id = 2, Name = "alt", Description = "Colloquial term for maps which use rhythms that encourage the player to alternate notes. Typically distinct from burst or stream maps.", }, + new APITag { Id = 3, Name = "aim", Description = "Category for difficulty relating to cursor movement.", }, + new APITag { Id = 4, Name = "tap", Description = "Category for difficulty relating to tapping input.", }, + ] + }), 500); + return true; + } + + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var beatmapSet = CreateAPIBeatmapSet(score.BeatmapInfo); + beatmapSet.Beatmaps.Single().TopTags = + [ + new APIBeatmapTag { TagId = 3, VoteCount = 9 }, + ]; + Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); + return true; + } + + case AddBeatmapTagRequest: + case RemoveBeatmapTagRequest: + { + Scheduler.AddDelayed(request.TriggerSuccess, 500); + return true; + } + } + + return false; + }; + }); + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); + } + + [Test] + public void TestTaggingWhenRankTooLow() + { + var score = TestResources.CreateTestScoreInfo(); + score.Rank = ScoreRank.D; + + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); + } + + [Test] + public void TestTaggingConvert() + { + var score = TestResources.CreateTestScoreInfo(); + score.Ruleset = new ManiaRuleset().RulesetInfo; + + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); } private void loadPanel(ScoreInfo score) => AddStep("load panel", () => { - Child = new UserStatisticsPanel(score) + Child = new StatisticsPanel { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, Score = { Value = score }, - DisplayedUserStatisticsUpdate = - { - Value = new ScoreBasedUserStatisticsUpdate(score, new UserStatistics - { - Level = new UserStatistics.LevelInfo - { - Current = 5, - Progress = 20, - }, - GlobalRank = 38000, - CountryRank = 12006, - PP = 2134, - RankedScore = 21123849, - Accuracy = 0.985, - PlayCount = 13375, - PlayTime = 354490, - TotalScore = 128749597, - TotalHits = 0, - MaxCombo = 1233, - }, new UserStatistics - { - Level = new UserStatistics.LevelInfo - { - Current = 5, - Progress = 30, - }, - GlobalRank = 36000, - CountryRank = 12000, - PP = (decimal)2134.5, - RankedScore = 23897015, - Accuracy = 0.984, - PlayCount = 13376, - PlayTime = 35789, - TotalScore = 132218497, - TotalHits = 0, - MaxCombo = 1233, - }) - } + AchievedScore = score, }; }); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs new file mode 100644 index 0000000000..d622df8d76 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneUserTagControl : OsuTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("set up working beatmap", () => + { + Beatmap.Value.BeatmapInfo.OnlineID = 42; + }); + AddStep("set up network requests", () => + { + dummyAPI.HandleRequest = request => + { + switch (request) + { + case ListTagsRequest listTagsRequest: + { + Scheduler.AddDelayed(() => listTagsRequest.TriggerSuccess(new APITagCollection + { + Tags = + [ + new APITag { Id = 1, Name = "tech", Description = "Tests uncommon skills.", }, + new APITag { Id = 2, Name = "alt", Description = "Colloquial term for maps which use rhythms that encourage the player to alternate notes. Typically distinct from burst or stream maps.", }, + new APITag { Id = 3, Name = "aim", Description = "Category for difficulty relating to cursor movement.", }, + new APITag { Id = 4, Name = "tap", Description = "Category for difficulty relating to tapping input.", }, + ] + }), 500); + return true; + } + + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var beatmapSet = CreateAPIBeatmapSet(Beatmap.Value.BeatmapInfo); + beatmapSet.Beatmaps.Single().TopTags = + [ + new APIBeatmapTag { TagId = 3, VoteCount = 9 }, + ]; + Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); + return true; + } + + case AddBeatmapTagRequest: + case RemoveBeatmapTagRequest: + { + Scheduler.AddDelayed(request.TriggerSuccess, 500); + return true; + } + } + + return false; + }; + }); + AddStep("create control", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new UserTagControl(Beatmap.Value.BeatmapInfo) + { + Width = 500, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index 5ca08e0bba..9f0dc75f84 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -136,6 +136,13 @@ namespace osu.Game.Tests.Visual.Settings public Bindable Rotation { get; } = new Bindable(); + public BindableFloat PressureThreshold { get; } = new BindableFloat + { + MinValue = 0f, + MaxValue = 1f, + Precision = 0.005f, + }; + public IBindable Tablet => tablet; private readonly Bindable tablet = new Bindable(); diff --git a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs new file mode 100644 index 0000000000..2c902a466f --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs @@ -0,0 +1,297 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; +using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public abstract partial class BeatmapCarouselV2TestScene : OsuManualInputManagerTestScene + { + protected readonly BindableList BeatmapSets = new BindableList(); + + protected BeatmapCarousel Carousel = null!; + + protected OsuScrollContainer Scroll => Carousel.ChildrenOfType>().Single(); + + [Cached(typeof(BeatmapStore))] + private BeatmapStore store; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private OsuTextFlowContainer stats = null!; + + private int beatmapCount; + + protected BeatmapCarouselV2TestScene() + { + store = new TestBeatmapStore + { + BeatmapSets = { BindTarget = BeatmapSets } + }; + + BeatmapSets.BindCollectionChanged((_, _) => beatmapCount = BeatmapSets.Sum(s => s.Beatmaps.Count)); + + Scheduler.AddDelayed(updateStats, 100, true); + } + + protected void CreateCarousel() + { + AddStep("create components", () => + { + Box topBox; + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 1), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 200), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 200), + }, + Content = new[] + { + new Drawable[] + { + topBox = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Cyan, + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + }, + new Drawable[] + { + Carousel = new BeatmapCarousel + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 800, + RelativeSizeAxes = Axes.Y, + }, + }, + new[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Cyan, + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + topBox.CreateProxy(), + } + } + }, + stats = new OsuTextFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + TextAnchor = Anchor.CentreLeft, + }, + }; + }); + } + + protected void SortBy(FilterCriteria criteria) => AddStep($"sort:{criteria.Sort} group:{criteria.Group}", () => Carousel.Filter(criteria)); + + protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType().Count(), () => Is.GreaterThan(0)); + protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False); + protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); + + protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down)); + protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up)); + protected void SelectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right)); + protected void SelectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left)); + + protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter)); + + protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null); + protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null); + + protected ICarouselPanel? GetSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.Selected.Value); + protected ICarouselPanel? GetKeyboardSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.KeyboardSelected.Value); + + protected void WaitForGroupSelection(int group, int panel) + { + AddUntilStep($"selected is group{group} panel{panel}", () => + { + var groupingFilter = Carousel.Filters.OfType().Single(); + + GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group); + // offset by one because the group itself is included in the items list. + CarouselItem item = groupingFilter.GroupItems[g].ElementAt(panel + 1); + + return ReferenceEquals(Carousel.CurrentSelection, item.Model); + }); + } + + protected void WaitForSelection(int set, int? diff = null) + { + AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => + { + if (diff != null) + return ReferenceEquals(Carousel.CurrentSelection, BeatmapSets[set].Beatmaps[diff.Value]); + + return BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection); + }); + } + + protected IEnumerable GetVisiblePanels() + where T : Drawable + { + return Carousel.ChildrenOfType().Single() + .ChildrenOfType() + .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) + .OrderBy(p => p.Y); + } + + protected void ClickVisiblePanel(int index) + where T : Drawable + { + AddStep($"click panel at index {index}", () => + { + Carousel.ChildrenOfType().Single() + .ChildrenOfType() + .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) + .OrderBy(p => p.Y) + .ElementAt(index) + .ChildrenOfType().Single() + .TriggerClick(); + }); + } + + protected void ClickVisiblePanelWithOffset(int index, Vector2 positionOffsetFromCentre) + where T : Drawable + { + AddStep($"move mouse to panel {index} with offset {positionOffsetFromCentre}", () => + { + var panel = Carousel.ChildrenOfType().Single() + .ChildrenOfType() + .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) + .OrderBy(p => p.Y) + .ElementAt(index); + + InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre + panel.ToScreenSpace(positionOffsetFromCentre) - panel.ToScreenSpace(Vector2.Zero)); + }); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + } + + /// + /// Add requested beatmap sets count to list. + /// + /// The count of beatmap sets to add. + /// If not null, the number of difficulties per set. If null, randomised difficulty count will be used. + /// Whether to randomise the metadata to make groupings more uniform. + protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null, bool randomMetadata = false) => AddStep($"add {count} beatmaps{(randomMetadata ? " with random data" : "")}", () => + { + for (int i = 0; i < count; i++) + BeatmapSets.Add(CreateTestBeatmapSetInfo(fixedDifficultiesPerSet, randomMetadata)); + }); + + protected static BeatmapSetInfo CreateTestBeatmapSetInfo(int? fixedDifficultiesPerSet, bool randomMetadata) + { + var beatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4)); + + if (randomMetadata) + { + char randomCharacter = getRandomCharacter(); + + var metadata = new BeatmapMetadata + { + // Create random metadata, then we can check if sorting works based on these + Artist = $"{randomCharacter}ome Artist " + RNG.Next(0, 9), + Title = $"{randomCharacter}ome Song (set id {beatmapSetInfo.OnlineID:000}) {Guid.NewGuid()}", + Author = { Username = $"{randomCharacter}ome Guy " + RNG.Next(0, 9) }, + }; + + foreach (var beatmap in beatmapSetInfo.Beatmaps) + beatmap.Metadata = metadata.DeepClone(); + } + + return beatmapSetInfo; + } + + private static long randomCharPointer; + + private static char getRandomCharacter() + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz*"; + return chars[(int)((randomCharPointer++ / 2) % chars.Length)]; + } + + protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear()); + + protected void RemoveFirstBeatmap() => + AddStep("remove first beatmap", () => + { + if (BeatmapSets.Count == 0) return; + + BeatmapSets.Remove(BeatmapSets.First()); + }); + + private void updateStats() + { + if (Carousel.IsNull()) + return; + + stats.Clear(); + createHeader("beatmap store"); + stats.AddParagraph($""" + sets: {BeatmapSets.Count} + beatmaps: {beatmapCount} + """); + createHeader("carousel"); + stats.AddParagraph($""" + sorting: {Carousel.IsFiltering} + tracked: {Carousel.ItemsTracked} + displayable: {Carousel.DisplayableItems} + displayed: {Carousel.VisibleItems} + selected: {Carousel.CurrentSelection} + """); + + void createHeader(string text) + { + stats.AddParagraph(string.Empty); + stats.AddParagraph(text, cp => + { + cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold); + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 97c46a11fc..11e754c868 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -16,6 +16,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; @@ -23,6 +24,7 @@ using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK.Input; @@ -42,6 +44,9 @@ namespace osu.Game.Tests.Visual.SongSelect private const int set_count = 5; private const int diff_count = 3; + [Cached(typeof(BeatmapStore))] + private TestBeatmapStore beatmaps = new TestBeatmapStore(); + [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { @@ -1329,7 +1334,8 @@ namespace osu.Game.Tests.Visual.SongSelect carouselAdjust?.Invoke(carousel); - carousel.BeatmapSets = beatmapSets; + beatmaps.BeatmapSets.Clear(); + beatmaps.BeatmapSets.AddRange(beatmapSets); (target ?? this).Child = carousel; }); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs new file mode 100644 index 0000000000..30ca26ce68 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelect +{ + /// + /// Covers common steps which can be used for manual testing. + /// + [TestFixture] + public partial class TestSceneBeatmapCarouselV2 : BeatmapCarouselV2TestScene + { + [Test] + [Explicit] + public void TestBasics() + { + CreateCarousel(); + RemoveAllBeatmaps(); + + AddBeatmaps(10, randomMetadata: true); + AddBeatmaps(10); + AddBeatmaps(1); + } + + [Test] + [Explicit] + public void TestSorting() + { + SortBy(new FilterCriteria { Sort = SortMode.Artist }); + SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); + SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist }); + } + + [Test] + [Explicit] + public void TestRemovals() + { + RemoveFirstBeatmap(); + RemoveAllBeatmaps(); + } + + [Test] + [Explicit] + public void TestAddRemoveRepeatedOps() + { + AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20); + AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20); + } + + [Test] + [Explicit] + public void TestMasking() + { + AddStep("disable masking", () => Scroll.Masking = false); + AddStep("enable masking", () => Scroll.Masking = true); + } + + [Test] + [Explicit] + public void TestPerformanceWithManyBeatmaps() + { + const int count = 200000; + + List generated = new List(); + + AddStep($"populate {count} test beatmaps", () => + { + generated.Clear(); + Task.Run(() => + { + for (int j = 0; j < count; j++) + generated.Add(CreateTestBeatmapSetInfo(3, true)); + }).ConfigureAwait(true); + }); + + AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3)); + AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2)); + AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count)); + + AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated)); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2ArtistGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2ArtistGrouping.cs new file mode 100644 index 0000000000..c378871eac --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2ArtistGrouping.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2ArtistGrouping : BeatmapCarouselV2TestScene + { + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist }); + + AddBeatmaps(10, 3, true); + WaitForDrawablePanels(); + } + + [Test] + public void TestOpenCloseGroupWithNoSelectionMouse() + { + AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddUntilStep("no sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + ClickVisiblePanel(0); + + AddUntilStep("some sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + ClickVisiblePanel(0); + + AddUntilStep("no sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + } + + [Test] + public void TestOpenCloseGroupWithNoSelectionKeyboard() + { + AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddUntilStep("no sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + SelectNextPanel(); + Select(); + + AddUntilStep("some sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddAssert("keyboard selected is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + CheckNoSelection(); + + Select(); + + AddUntilStep("no sets visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddAssert("keyboard selected is collapsed", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + CheckNoSelection(); + } + + [Test] + public void TestCarouselRemembersSelection() + { + SelectNextGroup(); + + object? selection = null; + + AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model); + + CheckHasSelection(); + AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); + AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + + RemoveAllBeatmaps(); + AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddBeatmaps(10); + WaitForDrawablePanels(); + + CheckHasSelection(); + AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); + + AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); + AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + + ClickVisiblePanel(0); + AddUntilStep("carousel item not visible", GetSelectedPanel, () => Is.Null); + + ClickVisiblePanel(0); + AddUntilStep("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + } + + [Test] + public void TestGroupSelectionOnHeader() + { + SelectNextGroup(); + WaitForGroupSelection(0, 1); + + SelectPrevPanel(); + SelectPrevPanel(); + + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + + SelectPrevGroup(); + + WaitForGroupSelection(0, 1); + AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + + SelectPrevGroup(); + + WaitForGroupSelection(0, 1); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + } + + [Test] + public void TestKeyboardSelection() + { + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + CheckNoSelection(); + + // open first group + Select(); + CheckNoSelection(); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + + SelectNextPanel(); + Select(); + WaitForGroupSelection(3, 1); + + SelectNextGroup(); + WaitForGroupSelection(3, 5); + + SelectNextGroup(); + WaitForGroupSelection(4, 1); + + SelectPrevGroup(); + WaitForGroupSelection(3, 5); + + SelectNextGroup(); + WaitForGroupSelection(4, 1); + + SelectNextGroup(); + WaitForGroupSelection(4, 5); + + SelectNextGroup(); + WaitForGroupSelection(0, 1); + + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + + SelectNextGroup(); + WaitForGroupSelection(0, 1); + + SelectNextPanel(); + SelectNextGroup(); + WaitForGroupSelection(1, 1); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2DifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2DifficultyGrouping.cs new file mode 100644 index 0000000000..239c693ee1 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2DifficultyGrouping.cs @@ -0,0 +1,194 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2DifficultyGrouping : BeatmapCarouselV2TestScene + { + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); + + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + } + + [Test] + public void TestOpenCloseGroupWithNoSelectionMouse() + { + AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + ClickVisiblePanel(0); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + CheckNoSelection(); + + ClickVisiblePanel(0); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + } + + [Test] + public void TestOpenCloseGroupWithNoSelectionKeyboard() + { + AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + SelectNextPanel(); + Select(); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + AddAssert("keyboard selected is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + CheckNoSelection(); + + Select(); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + AddAssert("keyboard selected is collapsed", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + CheckNoSelection(); + } + + [Test] + public void TestCarouselRemembersSelection() + { + SelectNextGroup(); + + object? selection = null; + + AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model); + + CheckHasSelection(); + AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); + AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + + RemoveAllBeatmaps(); + AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddBeatmaps(3); + WaitForDrawablePanels(); + + CheckHasSelection(); + AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); + + AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); + AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + + ClickVisiblePanel(0); + AddUntilStep("carousel item not visible", GetSelectedPanel, () => Is.Null); + + ClickVisiblePanel(0); + AddUntilStep("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + } + + [Test] + public void TestGroupSelectionOnHeaderKeyboard() + { + SelectNextGroup(); + WaitForGroupSelection(0, 0); + + SelectPrevPanel(); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + + SelectPrevGroup(); + + WaitForGroupSelection(0, 0); + AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + + SelectPrevGroup(); + + WaitForGroupSelection(0, 0); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + } + + [Test] + public void TestGroupSelectionOnHeaderMouse() + { + SelectNextGroup(); + WaitForGroupSelection(0, 0); + + AddAssert("keyboard selected panel is beatmap", GetKeyboardSelectedPanel, Is.TypeOf); + AddAssert("selected panel is beatmap", GetSelectedPanel, Is.TypeOf); + + ClickVisiblePanel(0); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf); + AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + + ClickVisiblePanel(0); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + + AddAssert("selected panel is still beatmap", GetSelectedPanel, Is.TypeOf); + } + + [Test] + public void TestKeyboardSelection() + { + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + CheckNoSelection(); + + // open first group + Select(); + CheckNoSelection(); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + + SelectNextPanel(); + Select(); + WaitForGroupSelection(0, 0); + + SelectNextGroup(); + WaitForGroupSelection(0, 1); + + SelectNextGroup(); + WaitForGroupSelection(0, 2); + + SelectPrevGroup(); + WaitForGroupSelection(0, 1); + + SelectPrevGroup(); + WaitForGroupSelection(0, 0); + + SelectPrevGroup(); + WaitForGroupSelection(2, 9); + } + + [Test] + public void TestInputHandlingWithinGaps() + { + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + // Clicks just above the first group panel should not actuate any action. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2 + 1))); + + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2))); + + AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels().Any()); + CheckNoSelection(); + + // Beatmap panels expand their selection area to cover holes from spacing. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 0); + + ClickVisiblePanelWithOffset(1, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 1); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2NoGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2NoGrouping.cs new file mode 100644 index 0000000000..b4048a5355 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2NoGrouping.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2NoGrouping : BeatmapCarouselV2TestScene + { + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + SortBy(new FilterCriteria { Sort = SortMode.Title }); + } + + /// + /// Keyboard selection via up and down arrows doesn't actually change the selection until + /// the select key is pressed. + /// + [Test] + public void TestKeyboardSelectionKeyRepeat() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + CheckNoSelection(); + + Select(); + CheckNoSelection(); + + AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); + checkSelectionIterating(false); + + AddStep("press up arrow", () => InputManager.PressKey(Key.Up)); + checkSelectionIterating(false); + + AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down)); + checkSelectionIterating(false); + + AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); + checkSelectionIterating(false); + + Select(); + CheckHasSelection(); + } + + /// + /// Keyboard selection via left and right arrows moves between groups, updating the selection + /// immediately. + /// + [Test] + public void TestGroupSelectionKeyRepeat() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + CheckNoSelection(); + + AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); + checkSelectionIterating(true); + + AddStep("press left arrow", () => InputManager.PressKey(Key.Left)); + checkSelectionIterating(true); + + AddStep("release right arrow", () => InputManager.ReleaseKey(Key.Right)); + checkSelectionIterating(true); + + AddStep("release left arrow", () => InputManager.ReleaseKey(Key.Left)); + checkSelectionIterating(false); + } + + [Test] + public void TestCarouselRemembersSelection() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + + SelectNextGroup(); + + object? selection = null; + + AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model); + + CheckHasSelection(); + AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); + AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + + RemoveAllBeatmaps(); + AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddBeatmaps(10); + WaitForDrawablePanels(); + + CheckHasSelection(); + AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null); + + AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); + + AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); + AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + } + + [Test] + public void TestTraversalBeyondStart() + { + const int total_set_count = 200; + + AddBeatmaps(total_set_count); + WaitForDrawablePanels(); + + SelectNextGroup(); + WaitForSelection(0, 0); + SelectPrevGroup(); + WaitForSelection(total_set_count - 1, 0); + } + + [Test] + public void TestTraversalBeyondEnd() + { + const int total_set_count = 200; + + AddBeatmaps(total_set_count); + WaitForDrawablePanels(); + + SelectPrevGroup(); + WaitForSelection(total_set_count - 1, 0); + SelectNextGroup(); + WaitForSelection(0, 0); + } + + [Test] + public void TestGroupSelectionOnHeader() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextGroup(); + SelectNextGroup(); + WaitForSelection(1, 0); + + SelectPrevPanel(); + SelectPrevGroup(); + WaitForSelection(1, 0); + + SelectPrevPanel(); + SelectNextGroup(); + WaitForSelection(1, 0); + } + + [Test] + public void TestKeyboardSelection() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + CheckNoSelection(); + + Select(); + WaitForSelection(3, 0); + + SelectNextPanel(); + WaitForSelection(3, 0); + + Select(); + WaitForSelection(3, 1); + + SelectNextPanel(); + WaitForSelection(3, 1); + + Select(); + WaitForSelection(3, 2); + + SelectNextPanel(); + WaitForSelection(3, 2); + + Select(); + WaitForSelection(4, 0); + } + + [Test] + public void TestEmptyTraversal() + { + SelectNextPanel(); + CheckNoSelection(); + + SelectNextGroup(); + CheckNoSelection(); + + SelectPrevPanel(); + CheckNoSelection(); + + SelectPrevGroup(); + CheckNoSelection(); + } + + [Test] + public void TestInputHandlingWithinGaps() + { + AddBeatmaps(2, 5); + WaitForDrawablePanels(); + + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + // Clicks just above the first group panel should not actuate any action. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelBeatmapSet.HEIGHT / 2 + 1))); + + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelBeatmapSet.HEIGHT / 2))); + + AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels().Any()); + WaitForSelection(0, 0); + + // Beatmap panels expand their selection area to cover holes from spacing. + ClickVisiblePanelWithOffset(1, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForSelection(0, 0); + + // Panels with higher depth will handle clicks in the gutters for simplicity. + ClickVisiblePanelWithOffset(2, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForSelection(0, 2); + + ClickVisiblePanelWithOffset(3, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForSelection(0, 3); + } + + private void checkSelectionIterating(bool isIterating) + { + object? selection = null; + + for (int i = 0; i < 3; i++) + { + AddStep("store selection", () => selection = Carousel.CurrentSelection); + if (isIterating) + AddUntilStep("selection changed", () => Carousel.CurrentSelection != selection); + else + AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Scrolling.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Scrolling.cs new file mode 100644 index 0000000000..890e1dd6e3 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Scrolling.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Testing; +using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2Scrolling : BeatmapCarouselV2TestScene + { + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + SortBy(new FilterCriteria()); + + AddBeatmaps(10); + WaitForDrawablePanels(); + } + + [Test] + public void TestScrollPositionMaintainedOnAddSecondSelected() + { + Quad positionBefore = default; + + AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First()); + AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType().Single(p => p.Selected.Value))); + + WaitForScrolling(); + + AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); + + RemoveFirstBeatmap(); + WaitForSorting(); + + AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, + () => Is.EqualTo(positionBefore)); + } + + [Test] + public void TestScrollPositionMaintainedOnAddLastSelected() + { + Quad positionBefore = default; + + AddStep("scroll to last item", () => Scroll.ScrollToEnd(false)); + + AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last().Beatmaps.Last()); + + WaitForScrolling(); + + AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); + + RemoveFirstBeatmap(); + WaitForSorting(); + AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, + () => Is.EqualTo(positionBefore)); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index c234cc8a9c..474d2ee6e3 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -12,6 +12,8 @@ using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; @@ -20,14 +22,16 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { - public partial class TestSceneBeatmapLeaderboard : OsuTestScene + public partial class TestSceneBeatmapLeaderboard : OsuManualInputManagerTestScene { private readonly FailableLeaderboard leaderboard; @@ -37,6 +41,7 @@ namespace osu.Game.Tests.Visual.SongSelect private ScoreManager scoreManager = null!; private RulesetStore rulesetStore = null!; private BeatmapManager beatmapManager = null!; + private PlaySongSelect songSelect = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -45,25 +50,36 @@ namespace osu.Game.Tests.Visual.SongSelect dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); + dependencies.CacheAs(songSelect = new PlaySongSelect()); Dependencies.Cache(Realm); return dependencies; } + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(songSelect); + } + public TestSceneBeatmapLeaderboard() { - AddRange(new Drawable[] + Add(new OsuContextMenuContainer { - dialogOverlay = new DialogOverlay + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Depth = -1 - }, - leaderboard = new FailableLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = BeatmapLeaderboardScope.Global, + dialogOverlay = new DialogOverlay + { + Depth = -1 + }, + leaderboard = new FailableLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = BeatmapLeaderboardScope.Global, + } } }); } @@ -165,6 +181,11 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Global); AddStep(@"New Scores", () => leaderboard.SetScores(generateSampleScores(new BeatmapInfo()))); + AddStep(@"New Scores with teams", () => leaderboard.SetScores(generateSampleScores(new BeatmapInfo()).Select(s => + { + s.User.Team = new APITeam(); + return s; + }))); } [Test] @@ -180,6 +201,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("ensure no scores displayed", () => leaderboard.SetScores(null)); AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure)); + AddStep(@"No team", () => leaderboard.SetErrorState(LeaderboardState.NoTeam)); AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter)); AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn)); AddStep(@"Ruleset unavailable", () => leaderboard.SetErrorState(LeaderboardState.RulesetUnavailable)); @@ -187,6 +209,40 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected)); } + [Test] + public void TestUseTheseModsDoesNotCopySystemMods() + { + AddStep(@"set scores", () => leaderboard.SetScores(leaderboard.Scores, new ScoreInfo + { + Position = 999, + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Ruleset = new OsuRuleset().RulesetInfo, + Mods = new Mod[] { new OsuModHidden(), new ModScoreV2(), }, + User = new APIUser + { + Id = 6602580, + Username = @"waaiiru", + CountryCode = CountryCode.ES, + } + })); + AddUntilStep("wait for scores", () => this.ChildrenOfType().Count(), () => Is.GreaterThan(0)); + AddStep("right click panel", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Right); + }); + AddStep("click use these mods", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("song select received HD", () => songSelect.Mods.Value.Any(m => m is OsuModHidden)); + AddAssert("song select did not receive SV2", () => !songSelect.Mods.Value.Any(m => m is ModScoreV2)); + } + private void showPersonalBestWithNullPosition() { leaderboard.SetScores(leaderboard.Scores, new ScoreInfo @@ -423,7 +479,7 @@ namespace osu.Game.Tests.Visual.SongSelect Accuracy = 0.5140, MaxCombo = 244, TotalScore = 1707827, - Date = DateTime.Now.AddMonths(-3), + Date = DateTime.Now.AddMonths(-10), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, BeatmapHash = beatmapInfo.Hash, diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index bd5c43d242..5c89e8a02c 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -12,10 +12,12 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Extensions; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -23,6 +25,8 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; using osu.Game.Tests.Resources; using osu.Game.Users; +using osu.Game.Utils; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -63,7 +67,7 @@ namespace osu.Game.Tests.Visual.SongSelect return 336; // recommended star rating of 2 case 1: - return 928; // SR 3 + return 973; // SR 3 case 2: return 1905; // SR 4 @@ -80,6 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] + [FlakyTest] public void TestPresentedBeatmapIsRecommended() { List beatmapSets = null; @@ -101,6 +106,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] + [FlakyTest] public void TestCurrentRulesetIsRecommended() { BeatmapSetInfo catchSet = null, mixedSet = null; @@ -137,6 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] + [FlakyTest] public void TestSecondBestRulesetIsRecommended() { BeatmapSetInfo osuSet = null, mixedSet = null; @@ -154,6 +161,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] + [FlakyTest] public void TestCorrectStarRatingIsUsed() { BeatmapSetInfo osuSet = null, maniaSet = null; @@ -170,6 +178,46 @@ namespace osu.Game.Tests.Visual.SongSelect presentAndConfirm(() => maniaSet, 5); } + [Test] + [FlakyTest] + public void TestBeatmapListingFilter() + { + AddStep("set playmode to taiko", () => ((DummyAPIAccess)API).LocalUser.Value.PlayMode = "taiko"); + + AddStep("open beatmap listing", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.B); + InputManager.ReleaseKey(Key.B); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("wait for load", () => Game.ChildrenOfType().SingleOrDefault()?.IsLoaded, () => Is.True); + + checkRecommendedDifficulty(3); + + AddStep("change mode filter to osu!", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(1).TriggerClick()); + + checkRecommendedDifficulty(2); + + AddStep("change mode filter to osu!taiko", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(2).TriggerClick()); + + checkRecommendedDifficulty(3); + + AddStep("change mode filter to osu!catch", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(3).TriggerClick()); + + checkRecommendedDifficulty(4); + + AddStep("change mode filter to osu!mania", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(4).TriggerClick()); + + checkRecommendedDifficulty(5); + + void checkRecommendedDifficulty(double starRating) + => AddAssert($"recommended difficulty is {starRating}", + () => Game.ChildrenOfType().Single().ChildrenOfType().ElementAt(1).Text.ToString(), + () => Is.EqualTo($"Recommended difficulty ({starRating.FormatStarRating()})")); + } + private BeatmapSetInfo importBeatmapSet(IEnumerable difficultyRulesets) { var rulesets = difficultyRulesets.ToArray(); @@ -201,7 +249,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); - AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.MatchesOnlineID(getImport().Beatmaps[expectedDiff - 1])); + AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(getImport().Beatmaps[expectedDiff - 1].OnlineID)); } protected override TestOsuGame CreateTestGame() => new NoBeatmapUpdateGame(LocalStorage, API); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 3a95aca6b9..d8ab367ebd 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -56,20 +56,20 @@ namespace osu.Game.Tests.Visual.SongSelect [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. // At a point we have isolated interactive test runs enough, this can likely be removed. Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(Realm); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(music = new MusicController()); // required to get bindables attached Add(music); - Add(detachedBeatmapStore); + Add(beatmapStore); Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } @@ -1239,7 +1239,6 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] - [Solo] public void TestHardDeleteHandledCorrectly() { createSongSelect(); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs index 0b0cd0317a..ff0f35576c 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -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 NUnit.Framework; using osu.Framework.Allocation; @@ -10,12 +9,14 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Online; using osu.Game.Tests.Resources; using osuTK.Input; @@ -31,6 +32,9 @@ namespace osu.Game.Tests.Visual.SongSelect private BeatmapSetInfo testBeatmapSetInfo = null!; + [Cached(typeof(BeatmapStore))] + private TestBeatmapStore beatmaps = new TestBeatmapStore(); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -246,13 +250,12 @@ namespace osu.Game.Tests.Visual.SongSelect private BeatmapCarousel createCarousel() { + beatmaps.BeatmapSets.Clear(); + beatmaps.BeatmapSets.Add(testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(5)); + return carousel = new BeatmapCarousel(new FilterCriteria()) { RelativeSizeAxes = Axes.Both, - BeatmapSets = new List - { - (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(5)), - } }; } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs index b7b0101a7c..8694722acc 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs @@ -6,16 +6,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Game.Graphics.Cursor; using osu.Game.Overlays; namespace osu.Game.Tests.Visual.SongSelectV2 { - public abstract partial class SongSelectComponentsTestScene : OsuTestScene + public abstract partial class SongSelectComponentsTestScene : OsuManualInputManagerTestScene { [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - protected override Container Content { get; } = new Container + protected override Container Content { get; } = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2DifficultyPanel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2DifficultyPanel.cs new file mode 100644 index 0000000000..1947721d5d --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2DifficultyPanel.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapCarouselV2DifficultyPanel : ThemeComparisonTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapInfo beatmap = null!; + + public TestSceneBeatmapCarouselV2DifficultyPanel() + : base(false) + { + } + + [SetUp] + public void SetUp() => Schedule(() => + { + var beatmapSet = beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => b.OnlineID == 241526) + ?? beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => !b.Protected) + ?? TestResources.CreateTestBeatmapSetInfo(); + + beatmap = beatmapSet.Beatmaps.First(); + }); + + [Test] + public void TestDisplay() + { + AddStep("display", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); + } + + [Test] + public void TestRandomBeatmap() + { + AddStep("random beatmap", () => + { + var randomSet = beatmaps.GetAllUsableBeatmapSets().MinBy(_ => RNG.Next()); + randomSet ??= TestResources.CreateTestBeatmapSetInfo(); + beatmap = randomSet.Beatmaps.MinBy(_ => RNG.Next())!; + + CreateThemedContent(OverlayColourScheme.Aquamarine); + }); + } + + [Test] + public void TestManiaRuleset() + { + AddToggleStep("mania ruleset", v => Ruleset.Value = v ? new ManiaRuleset().RulesetInfo : new OsuRuleset().RulesetInfo); + } + + protected override Drawable CreateContent() + { + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] + { + new PanelBeatmap + { + Item = new CarouselItem(beatmap) + }, + new PanelBeatmap + { + Item = new CarouselItem(beatmap), + KeyboardSelected = { Value = true } + }, + new PanelBeatmap + { + Item = new CarouselItem(beatmap), + Selected = { Value = true } + }, + new PanelBeatmap + { + Item = new CarouselItem(beatmap), + KeyboardSelected = { Value = true }, + Selected = { Value = true } + }, + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2GroupPanel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2GroupPanel.cs new file mode 100644 index 0000000000..9b07f01e52 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2GroupPanel.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapCarouselV2GroupPanel : ThemeComparisonTestScene + { + public TestSceneBeatmapCarouselV2GroupPanel() + : base(false) + { + } + + protected override Drawable CreateContent() + { + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] + { + new PanelGroup + { + Item = new CarouselItem(new GroupDefinition('A', "Group A")) + }, + new PanelGroup + { + Item = new CarouselItem(new GroupDefinition('A', "Group A")), + KeyboardSelected = { Value = true } + }, + new PanelGroup + { + Item = new CarouselItem(new GroupDefinition('A', "Group A")), + Expanded = { Value = true } + }, + new PanelGroup + { + Item = new CarouselItem(new GroupDefinition('A', "Group A")), + KeyboardSelected = { Value = true }, + Expanded = { Value = true } + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(1, "1")) + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(3, "3")), + Expanded = { Value = true } + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(5, "5")), + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(7, "7")), + Expanded = { Value = true } + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(8, "8")), + }, + new PanelGroupStarDifficulty + { + Item = new CarouselItem(new GroupDefinition(9, "9")), + Expanded = { Value = true } + }, + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2SetPanel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2SetPanel.cs new file mode 100644 index 0000000000..ef34394e12 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2SetPanel.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapCarouselV2SetPanel : ThemeComparisonTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapSetInfo beatmapSet = null!; + + public TestSceneBeatmapCarouselV2SetPanel() + : base(false) + { + } + + [SetUp] + public void SetUp() => Schedule(() => + { + beatmapSet = beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => b.OnlineID == 241526) + ?? beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => !b.Protected) + ?? TestResources.CreateTestBeatmapSetInfo(); + }); + + [Test] + public void TestDisplay() + { + AddStep("display", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); + } + + [Test] + public void TestRandomBeatmap() + { + AddStep("random beatmap", () => + { + var randomSet = beatmaps.GetAllUsableBeatmapSets().MinBy(_ => RNG.Next()); + randomSet ??= TestResources.CreateTestBeatmapSetInfo(); + beatmapSet = randomSet; + + CreateThemedContent(OverlayColourScheme.Aquamarine); + }); + } + + protected override Drawable CreateContent() + { + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] + { + new PanelBeatmapSet + { + Item = new CarouselItem(beatmapSet) + }, + new PanelBeatmapSet + { + Item = new CarouselItem(beatmapSet), + KeyboardSelected = { Value = true } + }, + new PanelBeatmapSet + { + Item = new CarouselItem(beatmapSet), + Expanded = { Value = true } + }, + new PanelBeatmapSet + { + Item = new CarouselItem(beatmapSet), + KeyboardSelected = { Value = true }, + Expanded = { Value = true } + }, + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2StandalonePanel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2StandalonePanel.cs new file mode 100644 index 0000000000..2dbe9e6cd1 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselV2StandalonePanel.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapCarouselV2StandalonePanel : ThemeComparisonTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapInfo beatmap = null!; + + public TestSceneBeatmapCarouselV2StandalonePanel() + : base(false) + { + } + + [SetUp] + public void SetUp() => Schedule(() => + { + var beatmapSet = beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => b.OnlineID == 241526) + ?? beatmaps.GetAllUsableBeatmapSets().FirstOrDefault(b => !b.Protected) + ?? TestResources.CreateTestBeatmapSetInfo(); + + beatmap = beatmapSet.Beatmaps.First(); + }); + + [Test] + public void TestDisplay() + { + AddStep("display", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); + } + + [Test] + public void TestRandomBeatmap() + { + AddStep("random beatmap", () => + { + var randomSet = beatmaps.GetAllUsableBeatmapSets().MinBy(_ => RNG.Next()); + randomSet ??= TestResources.CreateTestBeatmapSetInfo(); + beatmap = randomSet.Beatmaps.MinBy(_ => RNG.Next())!; + + CreateThemedContent(OverlayColourScheme.Aquamarine); + }); + } + + [Test] + public void TestManiaRuleset() + { + AddToggleStep("mania ruleset", v => Ruleset.Value = v ? new ManiaRuleset().RulesetInfo : new OsuRuleset().RulesetInfo); + } + + protected override Drawable CreateContent() + { + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] + { + new PanelBeatmapStandalone + { + Item = new CarouselItem(beatmap) + }, + new PanelBeatmapStandalone + { + Item = new CarouselItem(beatmap), + KeyboardSelected = { Value = true } + }, + new PanelBeatmapStandalone + { + Item = new CarouselItem(beatmap), + Selected = { Value = true } + }, + new PanelBeatmapStandalone + { + Item = new CarouselItem(beatmap), + KeyboardSelected = { Value = true }, + Selected = { Value = true } + }, + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs index fbbab3a604..5b717887e2 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; namespace osu.Game.Tests.Visual.SongSelectV2 { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs index a7d0d70c03..26d39c9203 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs @@ -7,9 +7,11 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets.Mania; @@ -22,6 +24,7 @@ using osu.Game.Screens.SelectV2.Leaderboards; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelectV2 { @@ -102,6 +105,69 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }); } + [Test] + public void TestUseTheseModsDoesNotCopySystemMods() + { + LeaderboardScoreV2 score = null!; + + AddStep("create content", () => + { + Children = new Drawable[] + { + fillFlow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 2f), + Shear = new Vector2(OsuGame.SHEAR, 0) + }, + drawWidthText = new OsuSpriteText(), + }; + + var scoreInfo = new ScoreInfo + { + Position = 999, + Rank = ScoreRank.X, + Accuracy = 1, + MaxCombo = 244, + TotalScore = RNG.Next(1_800_000, 2_000_000), + MaximumStatistics = { { HitResult.Great, 3000 } }, + Mods = new Mod[] { new OsuModHidden(), new ModScoreV2(), }, + Ruleset = new OsuRuleset().RulesetInfo, + User = new APIUser + { + Id = 6602580, + Username = @"waaiiru", + CountryCode = CountryCode.ES, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + }, + Date = DateTimeOffset.Now.AddYears(-2), + }; + + fillFlow.Add(score = new LeaderboardScoreV2(scoreInfo) + { + Rank = scoreInfo.Position, + Shear = Vector2.Zero, + }); + + score.Show(); + }); + AddStep("right click panel", () => + { + InputManager.MoveMouseTo(score); + InputManager.Click(MouseButton.Right); + }); + AddStep("click use these mods", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mods received HD", () => score.SelectedMods.Value.Any(m => m is OsuModHidden)); + AddAssert("mods did not receive SV2", () => !score.SelectedMods.Value.Any(m => m is ModScoreV2)); + } + public override void SetUpSteps() { AddToggleStep("toggle scoring mode", v => config.SetValue(OsuSetting.ScoreDisplayMode, v ? ScoringMode.Classic : ScoringMode.Standardised)); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index d43026c960..630f3c95ee 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -11,9 +11,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Database; using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko; using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; @@ -30,6 +35,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Cached] private readonly OsuLogo logo; + protected override bool UseOnlineAPI => true; + public TestSceneSongSelect() { Children = new Drawable[] @@ -49,6 +56,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }; } + [BackgroundDependencyLoader] + private void load() + { + RealmDetachedBeatmapStore beatmapStore; + + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); + Add(beatmapStore); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -62,8 +78,17 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { base.SetUpSteps(); - AddStep("load screen", () => Stack.Push(new Screens.SelectV2.SongSelectV2())); - AddUntilStep("wait for load", () => Stack.CurrentScreen is Screens.SelectV2.SongSelectV2 songSelect && songSelect.IsLoaded); + AddStep("load screen", () => Stack.Push(new Screens.SelectV2.SoloSongSelect())); + AddUntilStep("wait for load", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect songSelect && songSelect.IsLoaded); + } + + [Test] + public void TestRulesets() + { + AddStep("set osu ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("set taiko ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("set catch ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + AddStep("set mania ruleset", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); } #region Footer @@ -80,8 +105,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("modified", () => SelectedMods.Value = new List { new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); AddStep("modified + one", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); AddStep("modified + two", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); - AddStep("modified + three", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); - AddStep("modified + four", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDifficultyAdjust(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + three", + () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + four", + () => SelectedMods.Value = new List + { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDifficultyAdjust(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); AddWaitStep("wait", 3); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs index 5173cb5673..a7ca3cd18c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 base.SetUpSteps(); AddStep("press enter", () => InputManager.Key(Key.Enter)); AddWaitStep("wait", 5); - PushAndConfirm(() => new Screens.SelectV2.SongSelectV2()); + PushAndConfirm(() => new Screens.SelectV2.SoloSongSelect()); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneUpdateBeatmapSetButtonV2.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneUpdateBeatmapSetButtonV2.cs new file mode 100644 index 0000000000..ba3f2635b0 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneUpdateBeatmapSetButtonV2.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneUpdateBeatmapSetButtonV2 : OsuTestScene + { + private UpdateBeatmapSetButton button = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = button = new UpdateBeatmapSetButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + [Test] + public void TestNullBeatmap() + { + AddStep("null beatmap", () => button.BeatmapSet = null); + AddAssert("button invisible", () => button.Alpha == 0f); + } + + [Test] + public void TestUpdatedBeatmap() + { + AddStep("updated beatmap", () => button.BeatmapSet = new BeatmapSetInfo + { + Beatmaps = { new BeatmapInfo() } + }); + AddAssert("button invisible", () => button.Alpha == 0f); + } + + [Test] + public void TestNonUpdatedBeatmap() + { + AddStep("non-updated beatmap", () => button.BeatmapSet = new BeatmapSetInfo + { + Beatmaps = + { + new BeatmapInfo + { + MD5Hash = "test", + OnlineMD5Hash = "online", + LastOnlineUpdate = DateTimeOffset.Now, + } + } + }); + + AddAssert("button visible", () => button.Alpha == 1f); + } + } +} diff --git a/osu.Game.Tests/Visual/TestMultiplayerComponents.cs b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs index 1814fb70c8..e385ff3a03 100644 --- a/osu.Game.Tests/Visual/TestMultiplayerComponents.cs +++ b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs @@ -11,7 +11,6 @@ using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Screens; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.OnlinePlay; @@ -26,15 +25,12 @@ namespace osu.Game.Tests.Visual /// Provides a to be resolved as a dependency in the screen, /// which is typically a part of . /// Rebinds the to handle requests via a . - /// Provides a for the screen. /// ///

///
public partial class TestMultiplayerComponents : OsuScreen { - public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen => multiplayerScreen; - - public TestMultiplayerRoomManager RoomManager => multiplayerScreen.RoomManager; + public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen { get; } public IScreen CurrentScreen => screenStack.CurrentScreen; @@ -53,17 +49,17 @@ namespace osu.Game.Tests.Visual private BeatmapManager beatmapManager { get; set; } private readonly OsuScreenStack screenStack; - private readonly TestMultiplayer multiplayerScreen; + private readonly TestRoomRequestsHandler requestsHandler = new TestRoomRequestsHandler(); public TestMultiplayerComponents() { - multiplayerScreen = new TestMultiplayer(); + MultiplayerScreen = new Screens.OnlinePlay.Multiplayer.Multiplayer(); InternalChildren = new Drawable[] { userLookupCache, beatmapLookupCache, - MultiplayerClient = new TestMultiplayerClient(RoomManager), + MultiplayerClient = new TestMultiplayerClient(requestsHandler), screenStack = new OsuScreenStack { Name = nameof(TestMultiplayerComponents), @@ -71,13 +67,13 @@ namespace osu.Game.Tests.Visual } }; - screenStack.Push(multiplayerScreen); + screenStack.Push(MultiplayerScreen); } [BackgroundDependencyLoader] private void load(IAPIProvider api) { - ((DummyAPIAccess)api).HandleRequest = request => multiplayerScreen.RequestsHandler.HandleRequest(request, api.LocalUser.Value, beatmapManager); + ((DummyAPIAccess)api).HandleRequest = request => requestsHandler.HandleRequest(request, api.LocalUser.Value, beatmapManager); } public override bool OnBackButton() => (screenStack.CurrentScreen as OsuScreen)?.OnBackButton() ?? base.OnBackButton(); @@ -90,13 +86,5 @@ namespace osu.Game.Tests.Visual screenStack.Exit(); return true; } - - private partial class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer - { - public new TestMultiplayerRoomManager RoomManager { get; private set; } - public TestRoomRequestsHandler RequestsHandler { get; private set; } - - protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager(RequestsHandler = new TestRoomRequestsHandler()); - } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs index 2a2f267fc8..118e37dab4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs @@ -101,7 +101,16 @@ namespace osu.Game.Tests.Visual.UserInterface } } } - } + }, + } + }, + new OsuMenuItem(@"Another nested option") + { + Items = new MenuItem[] + { + new OsuMenuItem(@"Sub-One"), + new OsuMenuItem(@"Sub-Two"), + new OsuMenuItem(@"Sub-Three"), } }, new OsuMenuItem(@"Choose me please"), diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index e2fe10fa74..f7bdda6b57 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("click delete option", () => { - InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToString().ToLowerInvariant() == "delete")); + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => string.Equals(i.Item.Text.Value.ToString(), "delete", System.StringComparison.OrdinalIgnoreCase))); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs index 2dee57f4cb..4d180f6507 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Overlays.FirstRunSetup; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.UserInterface { @@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.UserInterface [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + [Cached(typeof(BeatmapStore))] + private BeatmapStore beatmapStore = new TestBeatmapStore(); + public TestSceneFirstRunScreenUIScale() { AddStep("load screen", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index 2ca06bf2f4..dc51e5516a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -17,12 +17,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.FirstRunSetup; using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osu.Game.Screens.Footer; +using osu.Game.Tests.Beatmaps; using osuTK; using osuTK.Input; @@ -47,6 +49,7 @@ namespace osu.Game.Tests.Visual.UserInterface Dependencies.Cache(LocalConfig = new OsuConfigManager(LocalStorage)); Dependencies.CacheAs(performer.Object); Dependencies.CacheAs(notificationOverlay.Object); + Dependencies.CacheAs(new TestBeatmapStore()); } [SetUpSteps] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs index c6fd65b973..2003f5de83 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -6,9 +6,11 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Screens.Edit.Setup; using osuTK; namespace osu.Game.Tests.Visual.UserInterface @@ -26,82 +28,102 @@ namespace osu.Game.Tests.Visual.UserInterface Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + Child = new OsuScrollContainer { - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 400, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5), - Padding = new MarginPadding(10), - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - new FormTextBox + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - TabbableContentContainer = this, - }, - new FormTextBox - { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - Current = { Disabled = true }, - TabbableContentContainer = this, - }, - new FormNumberBox - { - Caption = "Number", - HintText = "Insert your favourite number", - PlaceholderText = "Mine is 42!", - TabbableContentContainer = this, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Disabled = true }, - }, - new FormSliderBar - { - Caption = "Slider", - Current = new BindableFloat + new FormTextBox { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + TabbableContentContainer = this, }, - TabbableContentContainer = this, - }, - new FormEnumDropdown - { - Caption = EditorSetupStrings.EnableCountdown, - HintText = EditorSetupStrings.CountdownDescription, - }, - new FormFileSelector - { - Caption = "Audio file", - PlaceholderText = "Select an audio file", - }, - new FormColourPalette - { - Caption = "Combo colours", - Colours = + new FormTextBox { - Colour4.Red, - Colour4.Green, - Colour4.Blue, - Colour4.Yellow, - } + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Disabled = true }, + TabbableContentContainer = this, + }, + new FormNumberBox(allowDecimals: true) + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }, + new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormFileSelector + { + Caption = "File selector", + PlaceholderText = "Select a file", + }, + new FormBeatmapFileSelector(true) + { + Caption = "File selector with intermediate choice dialog", + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + new FormButton + { + Caption = "No text in button", + Action = () => { }, + }, + new FormButton + { + Caption = "Text in button which is pretty long and is very likely to wrap", + ButtonText = "Foo the bar", + Action = () => { }, + }, }, }, }, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs index c75c2a7877..c7e2a0ed4b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs @@ -1,16 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; namespace osu.Game.Tests.Visual.UserInterface { @@ -19,37 +20,90 @@ namespace osu.Game.Tests.Visual.UserInterface [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private FriendOnlineStreamControl control; + private TestMetadataClient metadataClient = null!; [SetUp] - public void SetUp() => Schedule(() => Child = control = new FriendOnlineStreamControl + public void SetUp() => Schedule(() => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new FriendOnlineStreamControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; }); [Test] - public void Populate() + public void TestChangeFriends() { - AddStep("Populate", () => control.Populate(new List + AddStep("set 10 friends", () => { - new APIUser + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(Enumerable.Range(1, 10).Select(i => new APIRelation { - IsOnline = true - }, - new APIUser - { - IsOnline = false - }, - new APIUser - { - IsOnline = false - } - })); + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); - AddAssert("3 users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.All)?.Count == 3); - AddAssert("1 online user", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Online)?.Count == 1); - AddAssert("2 offline users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Offline)?.Count == 2); + AddStep("set 20 friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(Enumerable.Range(1, 20).Select(i => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); + } + + [Test] + public void TestChangeOnlineStates() + { + AddStep("set 10 friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Friends.Clear(); + api.Friends.AddRange(Enumerable.Range(1, 10).Select(i => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); + + AddStep("make users 1-5 online", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddStep("make users 1-5 DnD", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.DoNotDisturb }); + }); + + AddStep("make users 1-5 offline", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, null); + }); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs index b40d0b10d2..30470c9c17 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { @@ -220,6 +221,29 @@ namespace osu.Game.Tests.Visual.UserInterface checkBindableAtValue("Circle Size", null); } + [Test] + public void TestResetToDefaultViaDoubleClickingNub() + { + setBeatmapWithDifficultyParameters(5); + + setSliderValue("Circle Size", 3); + setExtendedLimits(true); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", 3); + + AddStep("double click circle size nub", () => + { + var nub = this.ChildrenOfType.SliderNub>().First(); + InputManager.MoveMouseTo(nub); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + checkSliderAtValue("Circle Size", 5); + checkBindableAtValue("Circle Size", null); + } + [Test] public void TestModSettingChangeTracker() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 280497e861..499b28fb49 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -993,7 +993,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("column not scrolled", () => modSelectOverlay.ChildrenOfType().Single().IsScrolledToStart()); AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); - AddAssert("customisation panel closed", + AddUntilStep("customisation panel closed", () => this.ChildrenOfType().Single().ExpandedState.Value, () => Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); @@ -1018,7 +1018,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { AddUntilStep($"customisation panel is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.ChildrenOfType().Single().Enabled.Value == !disabled); - AddAssert($"customisation panel is {(active ? "" : "not ")}active", + AddUntilStep($"customisation panel is {(active ? "" : "not ")}active", () => modSelectOverlay.ChildrenOfType().Single().ExpandedState.Value, () => active ? Is.Not.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed) : Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); } @@ -1030,7 +1030,10 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowPresets => true; + public TestModSelectOverlay() + { + ShowPresets = true; + } } private class TestUnimplementedMod : Mod diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index c584c7dba0..65c8b913d3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -83,6 +83,40 @@ namespace osu.Game.Tests.Visual.UserInterface waitForCompletion(); } + [Test] + public void TestNormalDoesForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = false, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(1); + } + + [Test] + public void TestTransientDoesNotForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = true, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(0); + } + [Test] public void TestForwardWithFlingRight() { @@ -634,12 +668,18 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class BackgroundNotification : SimpleNotification { - public override bool IsImportant => false; + public BackgroundNotification() + { + IsImportant = false; + } } private partial class BackgroundProgressNotification : ProgressNotification { - public override bool IsImportant => false; + public BackgroundProgressNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs index 62a493815b..27d2ff97fa 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs @@ -4,20 +4,52 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Screens.Menu; +using osu.Game.Seasonal; +using osuTK; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneOsuLogo : OsuTestScene { + private OsuLogo? logo; + + private float scale = 1; + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("scale", 0.1, 2, 1, scale => + { + if (logo != null) + Child.Scale = new Vector2(this.scale = (float)scale); + }); + } + [Test] public void TestBasic() { AddStep("Add logo", () => { - Child = new OsuLogo + Child = logo = new OsuLogo { Anchor = Anchor.Centre, Origin = Anchor.Centre, + Scale = new Vector2(scale), + }; + }); + } + + [Test] + public void TestChristmas() + { + AddStep("Add logo", () => + { + Child = logo = new OsuLogoChristmas + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(scale), }; }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs index bb94912c83..e544fb127d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Overlays.Volume; @@ -59,13 +60,12 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestAltScrollNotBlocked() { - bool scrollReceived = false; + TestGlobalScrollAdjustsVolume volumeAdjust = null!; - AddStep("add volume control receptor", () => Add(new VolumeControlReceptor + AddStep("add volume control receptor", () => Add(volumeAdjust = new TestGlobalScrollAdjustsVolume { RelativeSizeAxes = Axes.Both, Depth = float.MaxValue, - ScrollActionRequested = (_, _, _) => scrollReceived = true, })); AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); @@ -75,10 +75,21 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.ScrollVerticalBy(10); }); - AddAssert("receptor received scroll input", () => scrollReceived); + AddAssert("receptor received scroll input", () => volumeAdjust.ScrollReceived); AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); } + public partial class TestGlobalScrollAdjustsVolume : GlobalScrollAdjustsVolume + { + public bool ScrollReceived { get; private set; } + + protected override bool OnScroll(ScrollEvent e) + { + ScrollReceived = true; + return base.OnScroll(e); + } + } + private partial class TestOverlay : OsuFocusedOverlayContainer { [BackgroundDependencyLoader] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs index a4cf8a276f..054bbb39d1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.UserInterface { private DependencyProvidingContainer contentContainer = null!; private ScreenFooter screenFooter = null!; - private TestModSelectOverlay modOverlay = null!; + private UserModSelectOverlay modOverlay = null!; [SetUp] public void SetUp() => Schedule(() => @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, Children = new Drawable[] { - modOverlay = new TestModSelectOverlay(), + modOverlay = new UserModSelectOverlay { ShowPresets = true }, new PopoverContainer { RelativeSizeAxes = Axes.Both, @@ -196,9 +196,35 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("external overlay content still not shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); } - private partial class TestModSelectOverlay : UserModSelectOverlay + [Test] + public void TestButtonResizedAfterFooterIsDisplayed() { - protected override bool ShowPresets => true; + TestShearedOverlayContainer externalOverlay = null!; + + AddStep("add overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); + AddStep("set buttons", () => screenFooter.SetButtons(new[] + { + new ScreenFooterButton(externalOverlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + })); + AddWaitStep("wait for transition", 3); + + AddStep("show overlay", () => externalOverlay.Show()); + AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); + + AddStep("resize active button", () => this.ChildrenOfType().First().ResizeWidthTo(240, 300, Easing.OutQuint)); + AddStep("resize active button back", () => this.ChildrenOfType().First().ResizeWidthTo(116, 300, Easing.OutQuint)); + + AddStep("hide overlay", () => externalOverlay.Hide()); + AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); } private partial class TestShearedOverlayContainer : ShearedOverlayContainer diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs index ba53eb83c4..e86f83ee15 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs @@ -115,11 +115,10 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowPresets => true; - public TestModSelectOverlay() : base(OverlayColourScheme.Aquamarine) { + ShowPresets = true; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 3a1eb554ab..7ec57c9e5e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("section top is visible", () => { var scrollContainer = container.ChildrenOfType().Single(); - float sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); + double sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); return scrollContainer.Current < sectionPosition; }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs new file mode 100644 index 0000000000..d650ce6c36 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearedDropdown : ThemeComparisonTestScene + { + public TestSceneShearedDropdown() + : base(false) + { + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black.Opacity(0.75f), + RelativeSizeAxes = Axes.Both, + }, + new ShearedDropdown("Test") + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 300f, + Width = 140, + Current = new Bindable(), + Items = new[] { "Global", "Friends", "Local", "Really lonnnnnnng option" }, + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs index 52543c68ce..c2b8ec76f4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Volume; @@ -11,7 +10,14 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneVolumeOverlay : OsuTestScene { - private VolumeOverlay volume; + private VolumeOverlay volume = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(volume = new VolumeOverlay()); + return dependencies; + } protected override void LoadComplete() { @@ -19,12 +25,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddRange(new Drawable[] { - volume = new VolumeOverlay(), - new VolumeControlReceptor + volume, + new GlobalScrollAdjustsVolume { RelativeSizeAxes = Axes.Both, - ActionRequested = action => volume.Adjust(action), - ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), }, }); diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 28a1d4d021..a1f43505f0 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,20 +1,20 @@  - + - + WinExe net8.0 - - tests.ruleset - + + + diff --git a/osu.Game.Tests/tests.ruleset b/osu.Game.Tests/tests.ruleset deleted file mode 100644 index a0abb781d3..0000000000 --- a/osu.Game.Tests/tests.ruleset +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs index de91a66e56..231bd77655 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs @@ -152,6 +152,12 @@ namespace osu.Game.Tournament.Tests.Components AddStep("change channel to 2", () => chatDisplay.Channel.Value = testChannel2); AddStep("change channel to 1", () => chatDisplay.Channel.Value = testChannel); + + AddStep("!mp message (shouldn't display)", () => testChannel.AddNewMessages(new Message(nextMessageId()) + { + Sender = redUser.ToAPIUser(), + Content = "!mp wangs" + })); } private int messageId; diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 04683cd83b..8437a1bc4e 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,9 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - + - + WinExe diff --git a/osu.Game.Tournament/Components/DrawableTeamFlag.cs b/osu.Game.Tournament/Components/DrawableTeamFlag.cs index aef854bb8d..90638a7758 100644 --- a/osu.Game.Tournament/Components/DrawableTeamFlag.cs +++ b/osu.Game.Tournament/Components/DrawableTeamFlag.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Tournament.Models; @@ -35,12 +36,20 @@ namespace osu.Game.Tournament.Components Size = new Vector2(75, 54); Masking = true; CornerRadius = 5; - Child = flagSprite = new Sprite + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.FromHex("333"), + }, + flagSprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit + }, }; (flag = team.FlagName.GetBoundCopy()).BindValueChanged(_ => flagSprite.Texture = textures.Get($@"Flags/{team.FlagName}"), true); diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index ae59e92e33..cff86cf0a1 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Screens.Menu; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -207,7 +208,7 @@ namespace osu.Game.Tournament.Components Children = new Drawable[] { new DiffPiece(stats), - new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.00}{srExtra}")) + new DiffPiece(("Star Rating", $"{beatmap.StarRating.FormatStarRating()}{srExtra}")) } }, new FillFlowContainer diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index 0998e606e9..c04dbdcdd6 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -72,7 +73,13 @@ namespace osu.Game.Tournament.Components public void Contract() => this.FadeOut(200); - protected override ChatLine CreateMessage(Message message) => new MatchMessage(message, ladderInfo); + protected override ChatLine? CreateMessage(Message message) + { + if (message.Content.StartsWith("!mp", StringComparison.Ordinal)) + return null; + + return new MatchMessage(message, ladderInfo); + } protected override StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new MatchChannel(channel); diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 250d5acaae..162379f4aa 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -71,6 +71,8 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] private LadderInfo ladderInfo { get; set; } = null!; + private readonly SettingsTextBox acronymTextBox; + public TeamRow(TournamentTeam team, TournamentScreen parent) { Model = team; @@ -112,7 +114,7 @@ namespace osu.Game.Tournament.Screens.Editors Width = 0.2f, Current = Model.FullName }, - new SettingsTextBox + acronymTextBox = new SettingsTextBox { LabelText = "Acronym", Width = 0.2f, @@ -177,6 +179,27 @@ namespace osu.Game.Tournament.Screens.Editors }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + Model.Acronym.BindValueChanged(acronym => + { + var teamsWithSameAcronym = ladderInfo.Teams + .Where(t => t.Acronym.Value == acronym.NewValue && t != Model) + .ToList(); + + if (teamsWithSameAcronym.Count > 0) + { + acronymTextBox.SetNoticeText( + $"Acronym '{acronym.NewValue}' is already in use by team{(teamsWithSameAcronym.Count > 1 ? "s" : "")}:\n" + + $"{string.Join(",\n", teamsWithSameAcronym)}", true); + } + else + acronymTextBox.ClearNoticeText(); + }, true); + } + private partial class LastYearPlacementSlider : RoundedSliderBar { public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText; diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 19273e3714..5a7c28d024 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -33,12 +33,12 @@ namespace osu.Game.Audio /// /// All valid sample addition constants. /// - public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; + public static readonly string[] ALL_ADDITIONS = [HIT_WHISTLE, HIT_FINISH, HIT_CLAP]; /// /// All valid bank constants. /// - public static IEnumerable AllBanks => new[] { BANK_NORMAL, BANK_SOFT, BANK_DRUM }; + public static readonly string[] ALL_BANKS = [BANK_NORMAL, BANK_SOFT, BANK_DRUM]; /// /// The name of the sample to load. diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 1d710e6395..d3ab86a8a0 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -6,9 +6,9 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Online; namespace osu.Game.Audio { @@ -30,7 +30,7 @@ namespace osu.Game.Audio [BackgroundDependencyLoader] private void load(AudioManager audioManager) { - trackStore = audioManager.GetTrackStore(new OnlineStore()); + trackStore = audioManager.GetTrackStore(new TrustedDomainOnlineStore()); } /// diff --git a/osu.Game/Beatmaps/APIBeatmapTag.cs b/osu.Game/Beatmaps/APIBeatmapTag.cs new file mode 100644 index 0000000000..5f4f9b851d --- /dev/null +++ b/osu.Game/Beatmaps/APIBeatmapTag.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public class APIBeatmapTag + { + [JsonProperty("tag_id")] + public long TagId { get; set; } + + [JsonProperty("count")] + public int VoteCount { get; set; } + } +} diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 282f8fe794..155ded5747 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -9,6 +9,7 @@ using System.Linq; using osu.Game.Beatmaps.ControlPoints; using Newtonsoft.Json; using osu.Framework.Lists; +using osu.Game.Beatmaps.Formats; using osu.Game.IO.Serialization.Converters; namespace osu.Game.Beatmaps @@ -115,6 +116,34 @@ namespace osu.Game.Beatmaps return mostCommon.beatLength; } + public double AudioLeadIn { get; set; } + + public float StackLeniency { get; set; } = 0.7f; + + public bool SpecialStyle { get; set; } + + public bool LetterboxInBreaks { get; set; } + + public bool WidescreenStoryboard { get; set; } = true; + + public bool EpilepsyWarning { get; set; } + + public bool SamplesMatchPlaybackRate { get; set; } + + public double DistanceSpacing { get; set; } = 1.0; + + public int GridSize { get; set; } + + public double TimelineZoom { get; set; } = 1.0; + + public CountdownType Countdown { get; set; } = CountdownType.None; + + public int CountdownOffset { get; set; } + + public int[] Bookmarks { get; set; } = Array.Empty(); + + public int BeatmapVersion { get; set; } = LegacyBeatmapEncoder.FIRST_LAZER_VERSION; + IBeatmap IBeatmap.Clone() => Clone(); public Beatmap Clone() => (Beatmap)MemberwiseClone(); diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index c43bd494e9..f0cb6d0484 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -73,6 +73,20 @@ namespace osu.Game.Beatmaps beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; beatmap.UnhandledEventLines = original.UnhandledEventLines; + beatmap.AudioLeadIn = original.AudioLeadIn; + beatmap.StackLeniency = original.StackLeniency; + beatmap.SpecialStyle = original.SpecialStyle; + beatmap.LetterboxInBreaks = original.LetterboxInBreaks; + beatmap.WidescreenStoryboard = original.WidescreenStoryboard; + beatmap.EpilepsyWarning = original.EpilepsyWarning; + beatmap.SamplesMatchPlaybackRate = original.SamplesMatchPlaybackRate; + beatmap.DistanceSpacing = original.DistanceSpacing; + beatmap.GridSize = original.GridSize; + beatmap.TimelineZoom = original.TimelineZoom; + beatmap.Countdown = original.Countdown; + beatmap.CountdownOffset = original.CountdownOffset; + beatmap.Bookmarks = original.Bookmarks; + beatmap.BeatmapVersion = original.BeatmapVersion; return beatmap; } diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 94144e4695..77aca5eecf 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -428,17 +428,7 @@ namespace osu.Game.Beatmaps Hash = hash, DifficultyName = decodedInfo.DifficultyName, OnlineID = decodedInfo.OnlineID, - AudioLeadIn = decodedInfo.AudioLeadIn, - StackLeniency = decodedInfo.StackLeniency, - SpecialStyle = decodedInfo.SpecialStyle, - LetterboxInBreaks = decodedInfo.LetterboxInBreaks, - WidescreenStoryboard = decodedInfo.WidescreenStoryboard, - EpilepsyWarning = decodedInfo.EpilepsyWarning, - SamplesMatchPlaybackRate = decodedInfo.SamplesMatchPlaybackRate, - DistanceSpacing = decodedInfo.DistanceSpacing, BeatDivisor = decodedInfo.BeatDivisor, - GridSize = decodedInfo.GridSize, - TimelineZoom = decodedInfo.TimelineZoom, MD5Hash = memoryStream.ComputeMD5Hash(), EndTimeObjectCount = decoded.HitObjects.Count(h => h is IHasDuration), TotalObjectCount = decoded.HitObjects.Count diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index d94c09d40f..a6b40a26de 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -6,14 +6,12 @@ using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; -using osu.Game.Rulesets.Edit; using osu.Game.Scoring; using Realms; @@ -127,69 +125,28 @@ namespace osu.Game.Beatmaps /// /// Reset any fetched online linking information (and history). /// - public void ResetOnlineInfo() + public void ResetOnlineInfo(bool resetOnlineId = true) { - OnlineID = -1; + if (resetOnlineId) + OnlineID = -1; LastOnlineUpdate = null; OnlineMD5Hash = string.Empty; if (Status != BeatmapOnlineStatus.LocallyModified) Status = BeatmapOnlineStatus.None; } - #region Properties we may not want persisted (but also maybe no harm?) - - public double AudioLeadIn { get; set; } - - public float StackLeniency { get; set; } = 0.7f; - - public bool SpecialStyle { get; set; } - - public bool LetterboxInBreaks { get; set; } - - public bool WidescreenStoryboard { get; set; } = true; - - public bool EpilepsyWarning { get; set; } - - public bool SamplesMatchPlaybackRate { get; set; } - /// /// The time at which this beatmap was last played by the local user. /// public DateTimeOffset? LastPlayed { get; set; } - /// - /// The ratio of distance travelled per time unit. - /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). - /// - /// - /// The most common method of understanding is that at a default value of 1.0, the time-to-distance ratio will match the slider velocity of the beatmap - /// at the current point in time. Increasing this value will make hit objects more spaced apart when compared to the cursor movement required to track a slider. - /// - /// This is only a hint property, used by the editor in implementations. It does not directly affect the beatmap or gameplay. - /// - public double DistanceSpacing { get; set; } = 1.0; - public int BeatDivisor { get; set; } = 4; - public int GridSize { get; set; } - - public double TimelineZoom { get; set; } = 1.0; - /// /// The time in milliseconds when last exiting the editor with this beatmap loaded. /// public double? EditorTimestamp { get; set; } - [Ignored] - public CountdownType Countdown { get; set; } = CountdownType.None; - - /// - /// The number of beats to move the countdown backwards (compared to its default location). - /// - public int CountdownOffset { get; set; } - - #endregion - public bool Equals(BeatmapInfo? other) { if (ReferenceEquals(this, other)) return true; @@ -275,11 +232,6 @@ namespace osu.Game.Beatmaps [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")] public int? MaxCombo { get; set; } - [Ignored] - public int[] Bookmarks { get; set; } = Array.Empty(); - - public int BeatmapVersion; - public BeatmapInfo Clone() => (BeatmapInfo)this.Detach().MemberwiseClone(); public override string ToString() => this.GetDisplayTitle(); diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index a82a288239..25f98c812c 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -1,15 +1,28 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Select; namespace osu.Game.Beatmaps { public static class BeatmapInfoExtensions { + /// + /// Given an , update length, BPM and object counts. + /// + public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBeatmap beatmap) + { + beatmapInfo.Length = beatmap.CalculatePlayableLength(); + beatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); + beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); + beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + } + /// /// A user-presentable display title representing this beatmap. /// @@ -59,7 +72,7 @@ namespace osu.Game.Beatmaps if (beatmapInfo.OnlineID <= 0 || beatmapInfo.BeatmapSet == null) return null; - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}"; } } } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index f1ce977d96..1e66b28b15 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -15,6 +15,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.Extensions; @@ -154,9 +155,20 @@ namespace osu.Game.Beatmaps DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; + foreach (var timingPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); + foreach (var effectPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.EffectPoints) + { + var clonedEffectPoint = (EffectControlPoint)effectPoint.DeepClone(); + + if (!rulesetInfo.Equals(referenceWorkingBeatmap.BeatmapInfo.Ruleset)) + clonedEffectPoint.ScrollSpeedBindable.SetDefault(); + + newBeatmap.ControlPointInfo.Add(clonedEffectPoint.Time, clonedEffectPoint); + } + return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } @@ -408,7 +420,7 @@ namespace osu.Game.Beatmaps // user requested abort return; - var video = b.Files.FirstOrDefault(f => OsuGameBase.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.OrdinalIgnoreCase))); + var video = b.Files.FirstOrDefault(f => SupportedExtensions.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.OrdinalIgnoreCase))); if (video != null) { @@ -463,11 +475,8 @@ namespace osu.Game.Beatmaps beatmapContent.BeatmapInfo = beatmapInfo; // Since now this is a locally-modified beatmap, we also set all relevant flags to indicate this. - // Importantly, the `ResetOnlineInfo()` call must happen before encoding, as online ID is encoded into the `.osu` file, - // which influences the beatmap checksums. beatmapInfo.LastLocalUpdate = DateTimeOffset.Now; beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; - beatmapInfo.ResetOnlineInfo(); Realm.Write(r => { @@ -521,6 +530,16 @@ namespace osu.Game.Beatmaps } } + public void MarkPlayed(BeatmapInfo beatmapSetInfo) => Realm.Run(r => + { + using var transaction = r.BeginWrite(); + + var beatmap = r.Find(beatmapSetInfo.ID)!; + beatmap.LastPlayed = DateTimeOffset.Now; + + transaction.Commit(); + }); + #region Implementation of ICanAcceptFiles public Task Import(params string[] paths) => beatmapImporter.Import(paths); diff --git a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs index 8a107ed486..1af0e7a9ee 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs @@ -41,9 +41,9 @@ namespace osu.Game.Beatmaps return null; if (ruleset != null) - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}"; - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}"; } } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index efb432b84e..64ac69bb07 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -51,7 +51,7 @@ namespace osu.Game.Beatmaps if (lookupScope != MetadataLookupScope.None) metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); - foreach (var beatmap in beatmapSet.Beatmaps) + foreach (BeatmapInfo beatmap in beatmapSet.Beatmaps) { difficultyCache.Invalidate(beatmap); @@ -63,10 +63,7 @@ namespace osu.Game.Beatmaps var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.Length = working.Beatmap.CalculatePlayableLength(); - beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); - beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration); - beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count; + beatmap.UpdateStatisticsFromBeatmap(working.Beatmap); } // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index d132b86052..510764694e 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -23,10 +20,12 @@ namespace osu.Game.Beatmaps /// public partial class DifficultyRecommender : Component { + public event Action? StarRatingUpdated; + private readonly LocalUserStatisticsProvider statisticsProvider; [Resolved] - private Bindable gameRuleset { get; set; } + private Bindable gameRuleset { get; set; } = null!; [Resolved] private RulesetStore rulesets { get; set; } = null!; @@ -78,10 +77,18 @@ namespace osu.Game.Beatmaps private void updateMapping(RulesetInfo ruleset, UserStatistics statistics) { - // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedDifficultyMapping[ruleset.ShortName] = Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; + // algorithm taken from https://github.com/ppy/osu-web/blob/027026fccc91525e39cee5d2f369f1b343eb1bf1/app/Models/UserStatistics/Model.php#L93-L94 + recommendedDifficultyMapping[ruleset.ShortName] = + ruleset.ShortName == @"taiko" + ? Math.Pow((double)(statistics.PP ?? 0), 0.35) * 0.27 + : Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; + + StarRatingUpdated?.Invoke(); } + public double? GetRecommendedStarRatingFor(RulesetInfo ruleset) + => recommendedDifficultyMapping.TryGetValue(ruleset.ShortName, out double starRating) ? starRating : null; + /// /// Find the recommended difficulty from a selection of available difficulties for the current local user. /// @@ -90,15 +97,14 @@ namespace osu.Game.Beatmaps /// /// A collection of beatmaps to select a difficulty from. /// The recommended difficulty, or null if a recommendation could not be provided. - [CanBeNull] - public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) + public BeatmapInfo? GetRecommendedBeatmap(IEnumerable beatmaps) { foreach (string r in orderedRulesets) { if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation)) continue; - BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => + BeatmapInfo? beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => { double difference = b.StarRating - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 599d1b380a..7b99ad40de 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -101,7 +101,7 @@ namespace osu.Game.Beatmaps.Drawables { if (Status == BeatmapOnlineStatus.None) { - this.FadeOut(animation_duration, Easing.OutQuint); + Hide(); return; } diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 3aa34a5580..16e143f9dc 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -292,7 +292,7 @@ namespace osu.Game.Beatmaps.Drawables "1407228 II-L - VANGUARD-1.osz", "1422686 II-L - VANGUARD-2.osz", "1429217 Street - Phi.osz", - "1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", + "1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/157 "1447478 Cres. - End Time.osz", "1449942 m108 - Crescent Sakura.osz", "1463778 MuryokuP - A tree without a branch.osz", @@ -336,8 +336,8 @@ namespace osu.Game.Beatmaps.Drawables "1854710 Blaster & Extra Terra - Spacecraft (Cut Ver.).osz", "1859322 Hino Isuka - Delightness Brightness.osz", "1884102 Maduk - Go (feat. Lachi) (Cut Ver.).osz", - "1884578 Neko Hacker - People People feat. Nanahira.osz", - "1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", + "1884578 Neko Hacker - People People feat. Nanahira.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/266 + "1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/108 "1905582 KINEMA106 - Fly Away (Cut Ver.).osz", "1934686 ARForest - Rainbow Magic!!.osz", "1963076 METAROOM - S.N.U.F.F.Y.osz", @@ -345,7 +345,6 @@ namespace osu.Game.Beatmaps.Drawables "1971951 James Landino - Shiba Paradise.osz", "1972518 Toromaru - Sleight of Hand.osz", "1982302 KINEMA106 - INVITE.osz", - "1983475 KNOWER - The Government Knows.osz", "2010165 Junk - Yellow Smile (bms edit).osz", "2022737 Andora - Euphoria (feat. WaMi).osz", "2025023 tephe - Genjitsu Escape.osz", diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs index a11ef0f95c..41513ec7a2 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs @@ -36,11 +36,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards Origin = Anchor.CentreLeft, TextSize = 13f }, - new DifficultySpectrumDisplay(beatmapSet) + new DifficultySpectrumDisplay { + BeatmapSet = beatmapSet, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DotSize = new Vector2(5, 10) } } }; diff --git a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs index 2fb3a8eee4..fc41c7c6dc 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; @@ -18,37 +17,26 @@ namespace osu.Game.Beatmaps.Drawables { public partial class DifficultySpectrumDisplay : CompositeDrawable { - private Vector2 dotSize = new Vector2(4, 8); + private IBeatmapSetInfo? beatmapSet; - public Vector2 DotSize + public IBeatmapSetInfo? BeatmapSet { - get => dotSize; + get => beatmapSet; set { - dotSize = value; + beatmapSet = value; if (IsLoaded) - updateDotDimensions(); + updateDisplay(); } } - private float dotSpacing = 1; + private FillFlowContainer flow = null!; - public float DotSpacing - { - get => dotSpacing; - set - { - dotSpacing = value; + private const int max_difficulties_before_collapsing = 12; - if (IsLoaded) - updateDotDimensions(); - } - } - - private readonly FillFlowContainer flow; - - public DifficultySpectrumDisplay(IBeatmapSetInfo beatmapSet) + [BackgroundDependencyLoader] + private void load() { AutoSizeAxes = Axes.Both; @@ -58,54 +46,80 @@ namespace osu.Game.Beatmaps.Drawables Spacing = new Vector2(10, 0), Direction = FillDirection.Horizontal, }; - - // matching web: https://github.com/ppy/osu-web/blob/d06d8c5e735eb1f48799b1654b528e9a7afb0a35/resources/assets/lib/beatmapset-panel.tsx#L127 - bool collapsed = beatmapSet.Beatmaps.Count() > 12; - - foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset).OrderBy(group => group.Key)) - flow.Add(new RulesetDifficultyGroup(rulesetGrouping.Key.OnlineID, rulesetGrouping, collapsed)); } protected override void LoadComplete() { base.LoadComplete(); - updateDotDimensions(); + updateDisplay(); } - private void updateDotDimensions() + private void updateDisplay() { foreach (var group in flow) + group.Alpha = 0; + + if (beatmapSet == null) { - group.DotSize = DotSize; - group.DotSpacing = DotSpacing; + foreach (var group in flow) + group.Beatmaps = []; + return; + } + + // matching web: https://github.com/ppy/osu-web/blob/d06d8c5e735eb1f48799b1654b528e9a7afb0a35/resources/assets/lib/beatmapset-panel.tsx#L127 + bool collapsed = beatmapSet.Beatmaps.Count() > max_difficulties_before_collapsing; + + foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset).OrderBy(group => group.Key)) + { + int rulesetId = rulesetGrouping.Key.OnlineID; + + var group = flow.SingleOrDefault(rg => rg.RulesetId == rulesetId); + + if (group == null) + { + group = new RulesetDifficultyGroup(rulesetId); + flow.Add(group); + flow.SetLayoutPosition(group, rulesetId); + } + + group.Alpha = 1; + group.Beatmaps = rulesetGrouping.ToArray(); + group.Collapsed = collapsed; } } private partial class RulesetDifficultyGroup : FillFlowContainer { - private readonly int rulesetId; - private readonly IEnumerable beatmapInfos; - private readonly bool collapsed; + public readonly int RulesetId; - public RulesetDifficultyGroup(int rulesetId, IEnumerable beatmapInfos, bool collapsed) - { - this.rulesetId = rulesetId; - this.beatmapInfos = beatmapInfos; - this.collapsed = collapsed; - } + private IBeatmapInfo[] beatmaps = []; - public Vector2 DotSize + public IBeatmapInfo[] Beatmaps { set { - foreach (var dot in Children.OfType()) - dot.Size = value; + beatmaps = value.OrderBy(bi => bi.StarRating).ToArray(); + updateDisplay(); } } - public float DotSpacing + private bool collapsed; + + public bool Collapsed { - set => Spacing = new Vector2(value, 0); + get => collapsed; + set + { + collapsed = value; + updateDisplay(); + } + } + + private OsuSpriteText countText = null!; + + public RulesetDifficultyGroup(int rulesetId) + { + RulesetId = rulesetId; } [BackgroundDependencyLoader] @@ -115,52 +129,83 @@ namespace osu.Game.Beatmaps.Drawables Spacing = new Vector2(1, 0); Direction = FillDirection.Horizontal; - var icon = rulesets.GetRuleset(rulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; + var icon = rulesets.GetRuleset(RulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; Add(icon.With(i => { i.Size = new Vector2(14); i.Anchor = i.Origin = Anchor.Centre; })); - if (!collapsed) + for (int i = 0; i < max_difficulties_before_collapsing; i++) + Add(new DifficultyDot()); + + Add(countText = new OsuSpriteText { - foreach (var beatmapInfo in beatmapInfos.OrderBy(bi => bi.StarRating)) - Add(new DifficultyDot(beatmapInfo.StarRating)); - } - else + Font = OsuFont.Default.With(size: 12), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 1 } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + countText.Alpha = collapsed ? 1 : 0; + countText.Text = beatmaps.Length.ToLocalisableString(@"N0"); + + var dots = this.OfType().ToArray(); + + for (int i = 0; i < max_difficulties_before_collapsing; i++) { - Add(new OsuSpriteText + var dot = dots[i]; + + if (collapsed || i >= beatmaps.Length) { - Text = beatmapInfos.Count().ToLocalisableString(@"N0"), - Font = OsuFont.Default.With(size: 12), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding { Bottom = 1 } - }); + dot.Alpha = 0; + continue; + } + + dot.Alpha = 1; + dot.StarDifficulty = beatmaps[i].StarRating; } } } - private partial class DifficultyDot : CircularContainer + private partial class DifficultyDot : Circle { - private readonly double starDifficulty; + private double starDifficulty; - public DifficultyDot(double starDifficulty) + public double StarDifficulty { - this.starDifficulty = starDifficulty; + get => starDifficulty; + set + { + starDifficulty = value; + updateColour(); + } } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Anchor = Origin = Anchor.Centre; - Masking = true; + [Resolved] + private OsuColour colours { get; set; } = null!; - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.ForStarDifficulty(starDifficulty) - }; + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(5, 10); + Anchor = Origin = Anchor.Centre; + + updateColour(); + } + + private void updateColour() + { + Colour = colours.ForStarDifficulty(starDifficulty); } } } diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 55ef6f705e..4119ffb636 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -5,7 +5,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,6 +13,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -156,7 +156,7 @@ namespace osu.Game.Beatmaps.Drawables displayedStars.BindValueChanged(s => { - starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.ToLocalisableString("0.00"); + starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.FormatStarRating(); background.Colour = colours.ForStarDifficulty(s.NewValue); diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 35067f4055..5dc73d8679 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -30,8 +30,8 @@ namespace osu.Game.Beatmaps { Metadata = new BeatmapMetadata { - Artist = "please load a beatmap!", - Title = "no beatmaps available!" + Artist = "please select or load a beatmap!", + Title = "no beatmap selected!" }, BeatmapSet = new BeatmapSetInfo(), Difficulty = new BeatmapDifficulty diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 4d7ac355e0..765f2be345 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -17,6 +17,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; +using osu.Game.Utils; namespace osu.Game.Beatmaps.Formats { @@ -50,7 +51,7 @@ namespace osu.Game.Beatmaps.Formats } /// - /// Whether or not beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes. + /// Whether beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes. /// public bool ApplyOffsets = true; @@ -78,10 +79,10 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseStreamInto(LineBufferedReader stream, Beatmap beatmap) { this.beatmap = beatmap; - this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; + this.beatmap.BeatmapVersion = FormatVersion; parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion); - applyLegacyDefaults(this.beatmap.BeatmapInfo); + ApplyLegacyDefaults(this.beatmap); base.ParseStreamInto(stream, beatmap); @@ -189,9 +190,13 @@ namespace osu.Game.Beatmaps.Formats /// This method's intention is to restore those legacy defaults. /// See also: https://osu.ppy.sh/wiki/en/Client/File_formats/Osu_%28file_format%29 /// - private static void applyLegacyDefaults(BeatmapInfo beatmapInfo) + internal static void ApplyLegacyDefaults(Beatmap beatmap) { - beatmapInfo.WidescreenStoryboard = false; + beatmap.WidescreenStoryboard = false; + // in a perfect world this would throw if osu! ruleset couldn't be found, + // but unfortunately there are "legitimate" cases where it's not there (i.e. ruleset test projects), + // so attempt to trudge on with whatever it is that's in `BeatmapInfo` if the lookup fails. + beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(0) ?? beatmap.BeatmapInfo.Ruleset; } protected override void ParseLine(Beatmap beatmap, Section section, string line) @@ -243,7 +248,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"AudioLeadIn": - beatmap.BeatmapInfo.AudioLeadIn = Parsing.ParseInt(pair.Value); + beatmap.AudioLeadIn = Parsing.ParseInt(pair.Value); break; case @"PreviewTime": @@ -260,7 +265,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"StackLeniency": - beatmap.BeatmapInfo.StackLeniency = Parsing.ParseFloat(pair.Value); + beatmap.StackLeniency = Parsing.ParseFloat(pair.Value); break; case @"Mode": @@ -268,31 +273,31 @@ namespace osu.Game.Beatmaps.Formats break; case @"LetterboxInBreaks": - beatmap.BeatmapInfo.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; + beatmap.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; break; case @"SpecialStyle": - beatmap.BeatmapInfo.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; + beatmap.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; break; case @"WidescreenStoryboard": - beatmap.BeatmapInfo.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; break; case @"EpilepsyWarning": - beatmap.BeatmapInfo.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; + beatmap.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; break; case @"SamplesMatchPlaybackRate": - beatmap.BeatmapInfo.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; + beatmap.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; break; case @"Countdown": - beatmap.BeatmapInfo.Countdown = Enum.Parse(pair.Value); + beatmap.Countdown = Enum.Parse(pair.Value); break; case @"CountdownOffset": - beatmap.BeatmapInfo.CountdownOffset = Parsing.ParseInt(pair.Value); + beatmap.CountdownOffset = Parsing.ParseInt(pair.Value); break; } } @@ -304,7 +309,7 @@ namespace osu.Game.Beatmaps.Formats switch (pair.Key) { case @"Bookmarks": - beatmap.BeatmapInfo.Bookmarks = pair.Value.Split(',').Select(v => + beatmap.Bookmarks = pair.Value.Split(',').Select(v => { bool result = int.TryParse(v, out int val); return new { result, val }; @@ -312,7 +317,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"DistanceSpacing": - beatmap.BeatmapInfo.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; case @"BeatDivisor": @@ -320,11 +325,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"GridSize": - beatmap.BeatmapInfo.GridSize = Parsing.ParseInt(pair.Value); + beatmap.GridSize = Parsing.ParseInt(pair.Value); break; case @"TimelineZoom": - beatmap.BeatmapInfo.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; } } @@ -446,7 +451,7 @@ namespace osu.Game.Beatmaps.Formats // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported // video extensions and handle similar to a background if it doesn't match. - if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) + if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) { beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; lineSupportedByEncoder = true; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 956d004602..787ae1c222 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -79,14 +79,14 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[General]"); if (!string.IsNullOrEmpty(beatmap.Metadata.AudioFile)) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); - writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); + writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.AudioLeadIn}")); writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); - writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.BeatmapInfo.Countdown}")); + writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.Countdown}")); writer.WriteLine(FormattableString.Invariant( $"SampleSet: {toLegacySampleBank(((beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePoints.FirstOrDefault() ?? SampleControlPoint.DEFAULT).SampleBank)}")); - writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); + writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.StackLeniency}")); writer.WriteLine(FormattableString.Invariant($"Mode: {onlineRulesetID}")); - writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.LetterboxInBreaks ? '1' : '0')}")); // if (beatmap.BeatmapInfo.UseSkinSprites) // writer.WriteLine(@"UseSkinSprites: 1"); // if (b.AlwaysShowPlayfield) @@ -95,14 +95,14 @@ namespace osu.Game.Beatmaps.Formats // writer.WriteLine(@"OverlayPosition: " + b.OverlayPosition); // if (!string.IsNullOrEmpty(b.SkinPreference)) // writer.WriteLine(@"SkinPreference:" + b.SkinPreference); - if (beatmap.BeatmapInfo.EpilepsyWarning) + if (beatmap.EpilepsyWarning) writer.WriteLine(@"EpilepsyWarning: 1"); - if (beatmap.BeatmapInfo.CountdownOffset > 0) - writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.BeatmapInfo.CountdownOffset}")); + if (beatmap.CountdownOffset > 0) + writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.CountdownOffset}")); if (onlineRulesetID == 3) - writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.BeatmapInfo.SpecialStyle ? '1' : '0')}")); - writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.BeatmapInfo.WidescreenStoryboard ? '1' : '0')}")); - if (beatmap.BeatmapInfo.SamplesMatchPlaybackRate) + writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.SpecialStyle ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.WidescreenStoryboard ? '1' : '0')}")); + if (beatmap.SamplesMatchPlaybackRate) writer.WriteLine(@"SamplesMatchPlaybackRate: 1"); } @@ -110,12 +110,12 @@ namespace osu.Game.Beatmaps.Formats { writer.WriteLine("[Editor]"); - if (beatmap.BeatmapInfo.Bookmarks.Length > 0) - writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.BeatmapInfo.Bookmarks)}")); - writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.BeatmapInfo.DistanceSpacing}")); + if (beatmap.Bookmarks.Length > 0) + writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.Bookmarks)}")); + writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.DistanceSpacing}")); writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}")); - writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.BeatmapInfo.GridSize}")); - writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.BeatmapInfo.TimelineZoom}")); + writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.GridSize}")); + writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.TimelineZoom}")); } private void handleMetadata(TextWriter writer) @@ -319,11 +319,13 @@ namespace osu.Game.Beatmaps.Formats SampleControlPoint createSampleControlPointFor(double time, IList samples) { int volume = samples.Max(o => o.Volume); + string bank = samples.Where(s => s.Name == HitSampleInfo.HIT_NORMAL).Select(s => s.Bank).FirstOrDefault() + ?? samples.Select(s => s.Bank).First(); int customIndex = samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo) ? samples.OfType().Max(o => o.CustomSampleBank) : -1; - return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, CustomSampleBank = customIndex }; + return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, SampleBank = bank, CustomSampleBank = customIndex }; } } @@ -349,7 +351,7 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[Colours]"); - for (int i = 0; i < colours.Count; i++) + for (int i = 0; i < Math.Min(colours.Count, LegacyBeatmapDecoder.MAX_COMBO_COLOUR_COUNT); i++) { var comboColour = colours[i]; @@ -447,60 +449,31 @@ namespace osu.Game.Beatmaps.Formats private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position) { - PathType? lastType = null; - for (int i = 0; i < pathData.Path.ControlPoints.Count; i++) { PathControlPoint point = pathData.Path.ControlPoints[i]; + // Note that lazer's encoding format supports specifying multiple curve types for a slider path, which is not supported by stable. + // Backwards compatibility with stable is handled by `LegacyBeatmapExporter` and `BezierConverter.ConvertToModernBezier()`. if (point.Type != null) { - // We've reached a new (explicit) segment! - - // Explicit segments have a new format in which the type is injected into the middle of the control point string. - // To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point. - // One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments - bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PERFECT_CURVE || i == pathData.Path.ControlPoints.Count - 1; - - // Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable. - // Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder. - if (i > 1) + switch (point.Type?.Type) { - // We need to use the absolute control point position to determine equality, otherwise floating point issues may arise. - Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position; - Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position; + case SplineType.BSpline: + writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|"); + break; - if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y) - needsExplicitSegment = true; - } + case SplineType.Catmull: + writer.Write("C|"); + break; - if (needsExplicitSegment) - { - switch (point.Type?.Type) - { - case SplineType.BSpline: - writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|"); - break; + case SplineType.PerfectCurve: + writer.Write("P|"); + break; - case SplineType.Catmull: - writer.Write("C|"); - break; - - case SplineType.PerfectCurve: - writer.Write("P|"); - break; - - case SplineType.Linear: - writer.Write("L|"); - break; - } - - lastType = point.Type; - } - else - { - // New segment with the same type - duplicate the control point - writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}|")); + case SplineType.Linear: + writer.Write("L|"); + break; } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index ca4fadf458..6c290c4f1c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -18,6 +18,8 @@ namespace osu.Game.Beatmaps.Formats { public const int LATEST_VERSION = 14; + public const int MAX_COMBO_COLOUR_COUNT = 8; + /// /// The .osu format (beatmap) version. /// @@ -126,7 +128,9 @@ namespace osu.Game.Beatmaps.Formats string[] split = pair.Value.Split(','); Color4 colour = convertSettingStringToColor4(split, allowAlpha, pair); - bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); + bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal) + && int.TryParse(pair.Key[5..], out int comboIndex) + && comboIndex >= 1 && comboIndex <= MAX_COMBO_COLOUR_COUNT; if (isCombo) { diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 2f9a256d31..7ee1010079 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps.Legacy; using osu.Game.IO; using osu.Game.Storyboards; using osu.Game.Storyboards.Commands; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -37,6 +38,17 @@ namespace osu.Game.Beatmaps.Formats SetFallbackDecoder(() => new LegacyStoryboardDecoder()); } + protected override Storyboard CreateTemplateObject() + { + var sb = base.CreateTemplateObject(); + + var beatmap = new Beatmap(); + LegacyBeatmapDecoder.ApplyLegacyDefaults(beatmap); + sb.Beatmap = beatmap; + + return sb; + } + protected override void ParseStreamInto(LineBufferedReader stream, Storyboard storyboard) { this.storyboard = storyboard; @@ -72,6 +84,10 @@ namespace osu.Game.Beatmaps.Formats case "UseSkinSprites": storyboard.UseSkinSprites = pair.Value == "1"; break; + + case @"WidescreenStoryboard": + storyboard.Beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + break; } } @@ -112,7 +128,7 @@ namespace osu.Game.Beatmaps.Formats // // This avoids potential weird crashes when ffmpeg attempts to parse an image file as a video // (see https://github.com/ppy/osu/issues/22829#issuecomment-1465552451). - if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant())) + if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant())) break; storyboard.GetLayer("Video").Add(storyboardSprite = new StoryboardVideo(path, offset)); diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 430a31769b..482bc73742 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -69,6 +70,47 @@ namespace osu.Game.Beatmaps /// double GetMostCommonBeatLength(); + double AudioLeadIn { get; internal set; } + + float StackLeniency { get; internal set; } + + bool SpecialStyle { get; internal set; } + + bool LetterboxInBreaks { get; internal set; } + + bool WidescreenStoryboard { get; internal set; } + + bool EpilepsyWarning { get; internal set; } + + bool SamplesMatchPlaybackRate { get; internal set; } + + /// + /// The ratio of distance travelled per time unit. + /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). + /// + /// + /// The most common method of understanding is that at a default value of 1.0, the time-to-distance ratio will match the slider velocity of the beatmap + /// at the current point in time. Increasing this value will make hit objects more spaced apart when compared to the cursor movement required to track a slider. + /// + /// This is only a hint property, used by the editor in implementations. It does not directly affect the beatmap or gameplay. + /// + double DistanceSpacing { get; internal set; } + + int GridSize { get; internal set; } + + double TimelineZoom { get; internal set; } + + CountdownType Countdown { get; internal set; } + + /// + /// The number of beats to move the countdown backwards (compared to its default location). + /// + int CountdownOffset { get; internal set; } + + int[] Bookmarks { get; internal set; } + + int BeatmapVersion { get; } + /// /// Creates a shallow-clone of this beatmap and returns it. /// @@ -121,7 +163,7 @@ namespace osu.Game.Beatmaps /// /// Find the total milliseconds between the first and last hittable objects, excluding any break time. /// - public static double CalculateDrainLength(this IBeatmap beatmap) => CalculatePlayableLength(beatmap.HitObjects) - beatmap.TotalBreakTime; + public static double CalculateDrainLength(this IBeatmap beatmap) => Math.Max(CalculatePlayableLength(beatmap.HitObjects) - beatmap.TotalBreakTime, 0); /// /// Find the timestamps in milliseconds of the start and end of the playable region. diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index 6fab66bf70..ca3f7cc354 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -13,6 +13,8 @@ namespace osu.Game.Beatmaps.Legacy NewCombo = 1 << 2, Spinner = 1 << 3, ComboOffset = (1 << 4) | (1 << 5) | (1 << 6), - Hold = 1 << 7 + Hold = 1 << 7, + + ObjectTypes = Circle | Slider | Spinner | Hold } } diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index 66fad6c8d8..d876ba55b2 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -104,15 +104,35 @@ namespace osu.Game.Beatmaps switch (getCacheVersion(db)) { - case 1: - // will eventually become irrelevant due to the monthly recycling of local caches - // can be removed 20250221 - return queryCacheVersion1(db, beatmapInfo, out onlineMetadata); - case 2: return queryCacheVersion2(db, beatmapInfo, out onlineMetadata); } } + + onlineMetadata = null; + return false; + } + catch (SqliteException sqliteException) + { + onlineMetadata = null; + + // There have been cases where the user's local database is corrupt. + // Let's attempt to identify these cases and re-initialise the local cache. + switch (sqliteException.SqliteErrorCode) + { + case 26: // SQLITE_NOTADB + case 11: // SQLITE_CORRUPT + // only attempt purge & re-download if there is no other refetch in progress + if (cacheDownloadRequest != null) + return false; + + tryPurgeCache(); + prepareLocalCache(); + return false; + } + + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} failed with unhandled sqlite error {sqliteException}."); + return false; } catch (Exception ex) { @@ -120,9 +140,22 @@ namespace osu.Game.Beatmaps onlineMetadata = null; return false; } + } - onlineMetadata = null; - return false; + private void tryPurgeCache() + { + log(@"Local metadata cache is corrupted; attempting purge."); + + try + { + File.Delete(storage.GetFullPath(cache_database_name)); + } + catch (Exception ex) + { + log($@"Failed to purge local metadata cache: {ex}"); + } + + log(@"Local metadata cache purged due to corruption."); } private SqliteConnection getConnection() => @@ -232,42 +265,6 @@ namespace osu.Game.Beatmaps } } - private bool queryCacheVersion1(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) - { - Debug.Assert(beatmapInfo.BeatmapSet != null); - - using var cmd = db.CreateCommand(); - - cmd.CommandText = - @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path"; - - cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); - - using var reader = cmd.ExecuteReader(); - - if (reader.Read()) - { - logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} (cache version 1)."); - - onlineMetadata = new OnlineBeatmapMetadata - { - BeatmapSetID = reader.GetInt32(0), - BeatmapID = reader.GetInt32(1), - BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), - BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), - AuthorID = reader.GetInt32(3), - MD5Hash = reader.GetString(4), - LastUpdated = reader.GetDateTimeOffset(5), - // TODO: DateSubmitted and DateRanked are not provided by local cache in this version. - }; - return true; - } - - onlineMetadata = null; - return false; - } - private bool queryCacheVersion2(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) { Debug.Assert(beatmapInfo.BeatmapSet != null); @@ -279,8 +276,12 @@ namespace osu.Game.Beatmaps SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` FROM `osu_beatmaps` AS `b` JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` - WHERE `b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path + WHERE (`b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path) + AND `b`.`approved` in (1, 2, 4) """; + // approved conditional can theoretically be removed as it was fixed in + // https://github.com/ppy/osu-onlinedb-generator/commit/489ac000775c3ff63bc914efb83cad0f6fbde261 + // but it's also safe to leave it (should not affect performance). cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); diff --git a/osu.Game/Beatmaps/MetadataUtils.cs b/osu.Game/Beatmaps/MetadataUtils.cs index 89c821c16c..1d2a3b5d01 100644 --- a/osu.Game/Beatmaps/MetadataUtils.cs +++ b/osu.Game/Beatmaps/MetadataUtils.cs @@ -15,7 +15,7 @@ namespace osu.Game.Beatmaps /// Returns if the character can be used in and fields. /// Characters not matched by this method can be placed in and . /// - public static bool IsRomanised(char c) => c <= 0xFF; + public static bool IsRomanised(char c) => char.IsAscii(c) && !char.IsControl(c); /// /// Returns if the string can be used in and fields. diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 07bf4c028a..8df57fd0c8 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -62,7 +62,12 @@ namespace osu.Game.Beatmaps #region Resource getters protected virtual Waveform GetWaveform() => new Waveform(null); - protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; + + protected virtual Storyboard GetStoryboard() => new Storyboard + { + BeatmapInfo = BeatmapInfo, + Beatmap = Beatmap, + }; protected abstract IBeatmap GetBeatmap(); public abstract Texture GetBackground(); @@ -198,6 +203,8 @@ namespace osu.Game.Beatmaps { try { + // TODO: This is a touch expensive and can become an issue if being accessed every Update call. + // Optimally we would not involve the async flow if things are already loaded. return loadBeatmapAsync().GetResultSafely(); } catch (AggregateException ae) @@ -228,11 +235,18 @@ namespace osu.Game.Beatmaps // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); - // The original beatmap version needs to be preserved as the database doesn't contain it - BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; - - // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) - b.BeatmapInfo = BeatmapInfo; + // Copy across values of key properties for which the database-backed model has data that the decoded beatmap isn't going to. + b.BeatmapInfo.ID = BeatmapInfo.ID; + b.BeatmapInfo.UserSettings = BeatmapInfo.UserSettings; + b.BeatmapInfo.BeatmapSet = BeatmapInfo.BeatmapSet; + b.BeatmapInfo.Status = BeatmapInfo.Status; + b.BeatmapInfo.OnlineID = BeatmapInfo.OnlineID; + b.BeatmapInfo.OnlineMD5Hash = BeatmapInfo.OnlineMD5Hash; + b.BeatmapInfo.LastLocalUpdate = BeatmapInfo.LastLocalUpdate; + b.BeatmapInfo.LastOnlineUpdate = BeatmapInfo.LastOnlineUpdate; + b.BeatmapInfo.LastPlayed = BeatmapInfo.LastPlayed; + b.BeatmapInfo.EditorTimestamp = BeatmapInfo.EditorTimestamp; + b.BeatmapInfo.StarRating = BeatmapInfo.StarRating; // this could be recomputed in the decoding process but it's a bit annoying to do. return b; }, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 8af74d11d8..30bbbbc1fe 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -89,7 +89,7 @@ namespace osu.Game.Beatmaps public virtual WorkingBeatmap GetWorkingBeatmap([CanBeNull] BeatmapInfo beatmapInfo) { - if (beatmapInfo?.BeatmapSet == null) + if (beatmapInfo == null || ReferenceEquals(beatmapInfo, DefaultBeatmap.BeatmapInfo)) return DefaultBeatmap; lock (workingCache) @@ -152,14 +152,25 @@ namespace osu.Game.Beatmaps return null; } - if (stream.ComputeMD5Hash() != BeatmapInfo.MD5Hash) + string streamMD5 = stream.ComputeMD5Hash(); + string streamSHA2 = stream.ComputeSHA2Hash(); + + if (streamMD5 != BeatmapInfo.MD5Hash) { Logger.Log($"Beatmap failed to load (file {BeatmapInfo.Path} does not have the expected hash).", level: LogLevel.Error); return null; } using (var reader = new LineBufferedReader(stream)) - return Decoder.GetDecoder(reader).Decode(reader); + { + var beatmap = Decoder.GetDecoder(reader).Decode(reader); + + beatmap.BeatmapInfo.MD5Hash = streamMD5; + beatmap.BeatmapInfo.Hash = streamSHA2; + beatmap.BeatmapInfo.UpdateStatisticsFromBeatmap(beatmap); + + return beatmap; + } } catch (Exception e) { diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 85af1d383d..c494b830d1 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -96,7 +96,6 @@ namespace osu.Game.Collections lastCreated = collections[changes.InsertedIndices[0]].ID; foreach (int i in changes.NewModifiedIndices) - { var updatedItem = collections[i]; diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index e86254329f..b0dd70227c 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -15,6 +15,7 @@ using osu.Framework.Localisation; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; @@ -27,7 +28,7 @@ namespace osu.Game.Collections /// public partial class DrawableCollectionListItem : OsuRearrangeableListItem>, IFilterable { - private const float item_height = 35; + private const float item_height = 45; private const float button_width = item_height * 0.75f; protected TextBox TextBox => content.TextBox; @@ -92,13 +93,11 @@ namespace osu.Game.Collections Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { - TextBox = new ItemTextBox + TextBox = new ItemTextBox(collection) { - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - CornerRadius = item_height / 2, + RelativeSizeAxes = Axes.X, + Height = item_height, CommitOnFocusLost = true, - PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } }, @@ -125,11 +124,57 @@ namespace osu.Game.Collections { protected override float LeftRightPadding => item_height / 2; + private const float count_text_size = 12; + + private readonly Live collection; + + private OsuSpriteText countText = null!; + + public ItemTextBox(Live collection) + { + this.collection = collection; + + CornerRadius = item_height / 2; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f); BackgroundFocused = colours.GreySeaFoam; + + if (collection.IsManaged) + { + TextContainer.Height *= (Height - count_text_size) / Height; + TextContainer.Margin = new MarginPadding { Bottom = count_text_size }; + + TextContainer.Add(countText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Depth = float.MinValue, + Font = OsuFont.Default.With(size: count_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = 2 }, + Colour = colours.Yellow + }); + + // interestingly, it is not required to subscribe to change notifications on this collection at all for this to work correctly. + // the reasoning for this is that `DrawableCollectionList` already takes out a subscription on the set of all `BeatmapCollection`s - + // but that subscription does not only cover *changes to the set of collections* (i.e. addition/removal/rearrangement of collections), + // but also covers *changes to the properties of collections*, which `BeatmapMD5Hashes` is one. + // when a collection item changes due to `BeatmapMD5Hashes` changing, the list item is deleted and re-inserted, thus guaranteeing this to work correctly. + int count = collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + + countText.Text = count == 1 + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + ? $"{count:#,0} item" + : $"{count:#,0} items"; + } + else + { + PlaceholderText = "Create a new collection"; + } } } @@ -210,7 +255,7 @@ namespace osu.Game.Collections private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } - public IEnumerable FilterTerms => [(LocalisableString)Model.Value.Name]; + public IEnumerable FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []); private bool matchingFilter = true; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 4f62db8cf7..76d06f3665 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; @@ -57,6 +58,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f, 0.01f); SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); + SetDefault(OsuSetting.BeatmapListingFeaturedArtistFilter, true); SetDefault(OsuSetting.ProfileCoverExpanded, true); @@ -94,6 +96,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); SetDefault(OsuSetting.NotifyOnPrivateMessage, true); + SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true); // Audio SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -162,12 +165,11 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Version, string.Empty); SetDefault(OsuSetting.ShowFirstRunSetup, true); + SetDefault(OsuSetting.ShowMobileDisclaimer, RuntimeInfo.IsMobile); SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false); - SetDefault(OsuSetting.SongSelectRightMouseScroll, false); - SetDefault(OsuSetting.Scaling, ScalingMode.Off); SetDefault(OsuSetting.SafeAreaConsiderations, true); SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f, 0.01f); @@ -202,11 +204,12 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.HideCountryFlags, false); SetDefault(OsuSetting.MultiplayerRoomFilter, RoomPermissionsFilter.All); + SetDefault(OsuSetting.MultiplayerShowInProgressFilter, true); SetDefault(OsuSetting.LastProcessedMetadataId, -1); SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); - SetDefault(OsuSetting.UserOnlineStatus, null); + SetDefault(OsuSetting.UserOnlineStatus, UserStatus.Online); SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true); SetDefault(OsuSetting.EditorTimelineShowBreaks, true); @@ -216,6 +219,10 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); SetDefault(OsuSetting.AlwaysRequireHoldingForPause, false); + SetDefault(OsuSetting.EditorShowStoryboard, true); + + SetDefault(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, true); + SetDefault(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, true); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -231,7 +238,7 @@ namespace osu.Game.Configuration public void Migrate() { - // arrives as 2020.123.0 + // arrives as 2020.123.0-lazer string rawVersion = Get(OsuSetting.Version); if (rawVersion.Length < 6) @@ -244,11 +251,14 @@ namespace osu.Game.Configuration if (!int.TryParse(pieces[0], out int year)) return; if (!int.TryParse(pieces[1], out int monthDay)) return; - // ReSharper disable once UnusedVariable - int combined = (year * 10000) + monthDay; + int combined = year * 10000 + monthDay; - // migrations can be added here using a condition like: - // if (combined < 20220103) { performMigration() } + if (combined < 20250214) + { + // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. + if (RuntimeInfo.IsMobile) + GetBindable(OsuSetting.UIScale).SetDefault(); + } } public override TrackedSettings CreateTrackedSettings() @@ -395,7 +405,6 @@ namespace osu.Game.Configuration Skin, ScreenshotFormat, ScreenshotCaptureMenuCursor, - SongSelectRightMouseScroll, BeatmapSkins, BeatmapColours, BeatmapHitsounds, @@ -413,6 +422,7 @@ namespace osu.Game.Configuration IntroSequence, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, + NotifyOnFriendPresenceChange, UIHoldActivationDelay, HitLighting, StarFountains, @@ -436,7 +446,12 @@ namespace osu.Game.Configuration EditorShowSpeedChanges, TouchDisableGameplayTaps, ModSelectTextSearchStartsActive, + + /// + /// The status for the current user to broadcast to other players. + /// UserOnlineStatus, + MultiplayerRoomFilter, HideCountryFlags, EditorTimelineShowTimingChanges, @@ -447,6 +462,12 @@ namespace osu.Game.Configuration EditorRotationOrigin, EditorTimelineShowBreaks, EditorAdjustExistingObjectsOnTimingChanges, - AlwaysRequireHoldingForPause + AlwaysRequireHoldingForPause, + MultiplayerShowInProgressFilter, + BeatmapListingFeaturedArtistFilter, + ShowMobileDisclaimer, + EditorShowStoryboard, + EditorSubmissionNotifyOnDiscussionReplies, + EditorSubmissionLoadInBrowserAfterSubmission, } } diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 18631f5d00..b816d1a88b 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -10,7 +8,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Play; +using osu.Game.Users; namespace osu.Game.Configuration { @@ -27,9 +25,12 @@ namespace osu.Game.Configuration SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false); SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); - SetDefault(Static.SeasonalBackgrounds, null); + SetDefault(Static.SeasonalBackgrounds, null); SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile); - SetDefault(Static.LastLocalUserScore, null); + SetDefault(Static.LastLocalUserScore, null); + SetDefault(Static.LastAppliedOffsetScore, null); + SetDefault(Static.UserOnlineActivity, null); + SetDefault(Static.AllBeatmapTags, null); } /// @@ -78,15 +79,26 @@ namespace osu.Game.Configuration TouchInputActive, /// - /// Contains the local user's last score (can be completed or aborted) after exiting . - /// Will be cleared to null when leaving . + /// Stores the local user's last score (can be completed or aborted). /// LastLocalUserScore, + /// + /// Stores the local user's last score which was used to apply an offset. + /// + LastAppliedOffsetScore, + /// /// Whether the intro animation for the daily challenge screen has been played once. /// This is reset when a new challenge is up. /// DailyChallengeIntroPlayed, + + /// + /// The activity for the current user to broadcast to other players. + /// + UserOnlineActivity, + + AllBeatmapTags, } } diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 1512b6be93..4e813fa2c7 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -76,8 +76,9 @@ namespace osu.Game.Database { Logger.Log("Beginning background data store processing.."); - checkForOutdatedStarRatings(); - processBeatmapSetsWithMissingMetrics(); + clearOutdatedStarRatings(); + populateMissingStarRatings(); + processOnlineBeatmapSetsWithNoUpdate(); // Note that the previous method will also update these on a fresh run. processBeatmapsWithMissingObjectCounts(); processScoresWithMissingStatistics(); @@ -100,7 +101,7 @@ namespace osu.Game.Database /// Check whether the databased difficulty calculation version matches the latest ruleset provided version. /// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated. /// - private void checkForOutdatedStarRatings() + private void clearOutdatedStarRatings() { foreach (var ruleset in rulesetStore.AvailableRulesets) { @@ -132,7 +133,86 @@ namespace osu.Game.Database } } - private void processBeatmapSetsWithMissingMetrics() + /// + /// This is split out from as a separate process to prevent high server-side load + /// from the firing online requests as part of the update. + /// Star rating recalculations can be ran strictly locally. + /// + private void populateMissingStarRatings() + { + HashSet beatmapIds = new HashSet(); + + Logger.Log("Querying for beatmaps with missing star ratings..."); + + realmAccess.Run(r => + { + foreach (var b in r.All().Where(b => b.StarRating < 0 && b.BeatmapSet != null)) + beatmapIds.Add(b.ID); + }); + + if (beatmapIds.Count == 0) + return; + + Logger.Log($"Found {beatmapIds.Count} beatmaps which require star rating reprocessing."); + + var notification = showProgressNotification(beatmapIds.Count, "Reprocessing star rating for beatmaps", "beatmaps' star ratings have been updated"); + + int processedCount = 0; + int failedCount = 0; + + Dictionary rulesetCache = new Dictionary(); + + Ruleset getRuleset(RulesetInfo rulesetInfo) + { + if (!rulesetCache.TryGetValue(rulesetInfo.ShortName, out var ruleset)) + ruleset = rulesetCache[rulesetInfo.ShortName] = rulesetInfo.CreateInstance(); + + return ruleset; + } + + foreach (Guid id in beatmapIds) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapIds.Count); + + sleepIfRequired(); + + var beatmap = realmAccess.Run(r => r.Find(id)?.Detach()); + + if (beatmap == null) + return; + + try + { + var working = beatmapManager.GetWorkingBeatmap(beatmap); + var ruleset = getRuleset(working.BeatmapInfo.Ruleset); + + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(working); + + double starRating = calculator.Calculate().StarRating; + realmAccess.Write(r => + { + if (r.Find(id) is BeatmapInfo liveBeatmapInfo) + liveBeatmapInfo.StarRating = starRating; + }); + ((IWorkingBeatmapCache)beatmapManager).Invalidate(beatmap); + ++processedCount; + } + catch (Exception e) + { + Logger.Log($"Background processing failed on {beatmap}: {e}"); + ++failedCount; + } + } + + completeNotification(notification, processedCount, beatmapIds.Count, failedCount); + } + + private void processOnlineBeatmapSetsWithNoUpdate() { HashSet beatmapSetIds = new HashSet(); @@ -148,12 +228,7 @@ namespace osu.Game.Database // of other possible ways), but for now avoid queueing if the user isn't logged in at startup. if (api.IsLoggedIn) { - foreach (var b in r.All().Where(b => (b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null)) && b.BeatmapSet != null)) - beatmapSetIds.Add(b.BeatmapSet!.ID); - } - else - { - foreach (var b in r.All().Where(b => b.StarRating < 0 && b.BeatmapSet != null)) + foreach (var b in r.All().Where(b => b.OnlineID > 0 && b.LastOnlineUpdate == null && b.BeatmapSet != null)) beatmapSetIds.Add(b.BeatmapSet!.ID); } }); @@ -161,10 +236,9 @@ namespace osu.Game.Database if (beatmapSetIds.Count == 0) return; - Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require reprocessing."); + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require online updates."); - // Technically this is doing more than just star ratings, but easier for the end user to understand. - var notification = showProgressNotification(beatmapSetIds.Count, "Reprocessing star rating for beatmaps", "beatmaps' star ratings have been updated"); + var notification = showProgressNotification(beatmapSetIds.Count, "Updating online data for beatmaps", "beatmaps' online data have been updated"); int processedCount = 0; int failedCount = 0; diff --git a/osu.Game/Database/BeatmapStore.cs b/osu.Game/Database/BeatmapStore.cs new file mode 100644 index 0000000000..9853e4b9cf --- /dev/null +++ b/osu.Game/Database/BeatmapStore.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; + +namespace osu.Game.Database +{ + /// + /// A store which contains a thread-safe representation of beatmaps available game-wide. + /// This exposes changes to available beatmaps, such as post-import or deletion. + /// + /// + /// The main goal of classes which implement this interface should be to provide change + /// tracking and thread safety in a performant way, rather than having to worry about such + /// concerns at the point of usage. + /// + public abstract partial class BeatmapStore : Component + { + /// + /// Get all available beatmaps. + /// + /// A cancellation token which allows early abort from the operation. + /// A bindable list of all available beatmap sets. + /// + /// This operation may block during the initial load process. + /// + /// It is generally expected that once a beatmap store is in a good state, the overhead of this call + /// should be negligible. + /// + public abstract IBindableList GetBeatmapSets(CancellationToken? cancellationToken); + } +} diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index eb48425588..e7e5ddb4d2 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -42,7 +42,10 @@ namespace osu.Game.Database return null; using var contentStreamReader = new LineBufferedReader(contentStream); - var beatmapContent = new LegacyBeatmapDecoder().Decode(contentStreamReader); + + // FIRST_LAZER_VERSION is specified here to avoid flooring object coordinates on decode via `(int)` casts. + // we will be making integers out of them lower down, but in a slightly different manner (rounding rather than truncating) + var beatmapContent = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION).Decode(contentStreamReader); var workingBeatmap = new FlatWorkingBeatmap(beatmapContent); var playableBeatmap = workingBeatmap.GetPlayableBeatmap(beatmapInfo.Ruleset); @@ -58,6 +61,20 @@ namespace osu.Game.Database Configuration = new LegacySkinDecoder().Decode(skinStreamReader) }; + MutateBeatmap(model, playableBeatmap); + + // Encode to legacy format + var stream = new MemoryStream(); + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + return stream; + } + + protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { // Convert beatmap elements to be compatible with legacy format // So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves @@ -93,6 +110,12 @@ namespace osu.Game.Database hitObject.StartTime = Math.Floor(hitObject.StartTime); + if (hitObject is IHasXPosition hasXPosition) + hasXPosition.X = MathF.Round(hasXPosition.X); + + if (hitObject is IHasYPosition hasYPosition) + hasYPosition.Y = MathF.Round(hasYPosition.Y); + if (hitObject is not IHasPath hasPath) continue; // stable's hit object parsing expects the entire slider to use only one type of curve, @@ -111,28 +134,31 @@ namespace osu.Game.Database if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1 && hasPath.Path.ControlPoints[0].Type!.Value.Degree == null) continue; - var newControlPoints = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints); - - // Truncate control points to integer positions - foreach (var pathControlPoint in newControlPoints) - { - pathControlPoint.Position = new Vector2( - (float)Math.Floor(pathControlPoint.Position.X), - (float)Math.Floor(pathControlPoint.Position.Y)); - } + var convertedToBezier = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints); hasPath.Path.ControlPoints.Clear(); - hasPath.Path.ControlPoints.AddRange(newControlPoints); + + for (int i = 0; i < convertedToBezier.Count; i++) + { + var convertedPoint = convertedToBezier[i]; + + // Truncate control points to integer positions + var position = new Vector2( + (float)Math.Floor(convertedPoint.Position.X), + (float)Math.Floor(convertedPoint.Position.Y)); + + // stable only supports a single curve type specification per slider. + // we exploit the fact that the converted-to-Bézier path only has Bézier segments, + // and thus we specify the Bézier curve type once ever at the start of the slider. + hasPath.Path.ControlPoints.Add(new PathControlPoint(position, i == 0 ? PathType.BEZIER : null)); + + // however, the Bézier path as output by the converter has multiple segments. + // `LegacyBeatmapEncoder` will attempt to encode this by emitting per-control-point curve type specs which don't do anything for stable. + // instead, stable expects control points that start a segment to be present in the path twice in succession. + if (convertedPoint.Type == PathType.BEZIER && i > 0) + hasPath.Path.ControlPoints.Add(new PathControlPoint(position)); + } } - - // Encode to legacy format - var stream = new MemoryStream(); - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); - - stream.Seek(0, SeekOrigin.Begin); - - return stream; } protected override string FileExtension => @".osz"; diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index f9164e34cd..80393c27f7 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.IO; using osu.Game.Overlays.Notifications; using osu.Game.Utils; using Realms; @@ -46,7 +47,7 @@ namespace osu.Game.Database protected LegacyExporter(Storage storage) { - exportStorage = storage.GetStorageForDirectory(@"exports"); + exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); UserFileStorage = storage.GetStorageForDirectory(@"files"); } diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index dfeec259fe..8e89db4d06 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -131,8 +131,6 @@ namespace osu.Game.Database private partial class DownloadNotification : ProgressNotification { - public override bool IsImportant => false; - protected override Notification CreateCompletionNotification() => new SilencedProgressCompletionNotification { Activated = CompletionClickAction, @@ -141,7 +139,10 @@ namespace osu.Game.Database private partial class SilencedProgressCompletionNotification : ProgressCompletionNotification { - public override bool IsImportant => false; + public SilencedProgressCompletionNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index eb7182820b..7142f2b300 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -11,7 +11,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading; using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Extensions; @@ -94,8 +93,13 @@ namespace osu.Game.Database /// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances. /// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction /// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user. + /// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm. + /// 45 2024-12-23 Change beat snap divisor adjust defaults to be Ctrl+Scroll instead of Ctrl+Shift+Scroll, if not already changed by user. + /// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯. + /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. + /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). ///
- private const int schema_version = 43; + private const int schema_version = 48; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -312,6 +316,17 @@ namespace osu.Game.Database attemptRecoverFromFile(newerVersionFilename); } + try + { + // Some platforms' realm implementation (including windows) don't update modified time on open. + // Let's do this explicitly as some users may depend on it roughly aligning to usage expectations. + string fullPath = storage.GetFullPath(Filename); + var fi = new FileInfo(fullPath); + if (fi.Exists) + fi.LastWriteTime = DateTime.Now; + } + catch { } + try { return getRealmInstance(); @@ -410,18 +425,7 @@ namespace osu.Game.Database /// Compact this realm. /// /// - public bool Compact() - { - try - { - return Realm.Compact(getConfiguration()); - } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return true; - } - } + public bool Compact() => Realm.Compact(getConfiguration()); /// /// Run work on realm with a return value. @@ -717,11 +721,6 @@ namespace osu.Game.Database return Realm.GetInstance(getConfiguration()); } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return Realm.GetInstance(); - } finally { if (tookSemaphoreLock) @@ -1204,6 +1203,58 @@ namespace osu.Game.Database break; } + + case 45: + { + // Cycling beat snap divisors no longer requires holding shift (just control). + var keyBindings = migration.NewRealm.All(); + + var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor); + if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelLeft })) + migration.NewRealm.Remove(nextBeatSnapBinding); + + var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor); + if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelRight })) + migration.NewRealm.Remove(previousBeatSnapBinding); + + break; + } + + case 46: + { + // Stable direction didn't match. + var keyBindings = migration.NewRealm.All(); + + var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor); + if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelDown })) + migration.NewRealm.Remove(nextBeatSnapBinding); + + var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor); + if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelUp })) + migration.NewRealm.Remove(previousBeatSnapBinding); + + break; + } + + case 47: + { + var keyBindings = migration.NewRealm.All(); + + var existingBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.AbsoluteScrollSongList); + if (existingBinding != null && existingBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.MouseRight })) + migration.NewRealm.Remove(existingBinding); + + break; + } + + case 48: + const int qualified = (int)BeatmapOnlineStatus.Qualified; + + var beatmaps = migration.NewRealm.All().Where(b => b.StatusInt == qualified); + + foreach (var beatmap in beatmaps) + beatmap.ResetOnlineInfo(resetOnlineId: false); + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Database/DetachedBeatmapStore.cs b/osu.Game/Database/RealmDetachedBeatmapStore.cs similarity index 96% rename from osu.Game/Database/DetachedBeatmapStore.cs rename to osu.Game/Database/RealmDetachedBeatmapStore.cs index 5b65f608b2..b05e07ef31 100644 --- a/osu.Game/Database/DetachedBeatmapStore.cs +++ b/osu.Game/Database/RealmDetachedBeatmapStore.cs @@ -8,14 +8,13 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; using Realms; namespace osu.Game.Database { - public partial class DetachedBeatmapStore : Component + public partial class RealmDetachedBeatmapStore : BeatmapStore { private readonly ManualResetEventSlim loaded = new ManualResetEventSlim(); @@ -28,7 +27,7 @@ namespace osu.Game.Database [Resolved] private RealmAccess realm { get; set; } = null!; - public IBindableList GetDetachedBeatmaps(CancellationToken? cancellationToken) + public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) { loaded.Wait(cancellationToken ?? CancellationToken.None); return detachedBeatmapSets.GetBoundCopy(); diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 2fa3b8a880..538ac1dff7 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -248,29 +248,30 @@ namespace osu.Game.Database return new RealmLive(realmObject, realm); } +#pragma warning disable RS0030 // mentioning banned symbols in documentation /// - /// Register a callback to be invoked each time this changes. + /// Register a callback to be invoked each time this changes. /// /// /// /// This adds osu! specific thread and managed state safety checks on top of . /// /// - /// The first callback will be invoked with the initial after the asynchronous query completes, + /// The first callback will be invoked with the initial after the asynchronous query completes, /// and then called again after each write transaction which changes either any of the objects in the collection, or /// which objects are in the collection. The changes parameter will /// be null the first time the callback is invoked with the initial results. For each call after that, /// it will contain information about which rows in the results were added, removed or modified. /// /// - /// If a write transaction did not modify any objects in this , the callback is not invoked at all. + /// If a write transaction did not modify any objects in this , the callback is not invoked at all. /// If an error occurs the callback will be invoked with null for the sender parameter and a non-null error. - /// Currently the only errors that can occur are when opening the on the background worker thread. + /// Currently, the only errors that can occur are when opening the on the background worker thread. /// /// - /// At the time when the block is called, the object will be fully evaluated + /// At the time when the block is called, the object will be fully evaluated /// and up-to-date, and as long as you do not perform a write transaction on the same thread - /// or explicitly call , accessing it will never perform blocking work. + /// or explicitly call , accessing it will never perform blocking work. /// /// /// Notifications are delivered via the standard event loop, and so can't be delivered while the event loop is blocked by other activity. @@ -279,13 +280,14 @@ namespace osu.Game.Database /// /// /// The to observe for changes. - /// The callback to be invoked with the updated . + /// The callback to be invoked with the updated . /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. - /// To stop receiving notifications, call . + /// To stop receiving notifications, call . /// - /// - /// + /// + /// +#pragma warning restore RS0030 public static IDisposable QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase { diff --git a/osu.Game/Database/RealmResetEmptySet.cs b/osu.Game/Database/RealmResetEmptySet.cs index 9f9a1ba6d7..0daedc9633 100644 --- a/osu.Game/Database/RealmResetEmptySet.cs +++ b/osu.Game/Database/RealmResetEmptySet.cs @@ -46,7 +46,8 @@ namespace osu.Game.Database } public IRealmCollection Freeze() => throw new NotImplementedException(); - public IDisposable SubscribeForNotifications(NotificationCallbackDelegate callback) => throw new NotImplementedException(); + public IDisposable SubscribeForNotifications(NotificationCallbackDelegate callback, KeyPathsCollection? keyPathCollection = null) => throw new NotImplementedException(); + public bool IsValid => throw new NotImplementedException(); public Realm Realm => throw new NotImplementedException(); public ObjectSchema ObjectSchema => throw new NotImplementedException(); diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index db44731bed..15e3da3c19 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -245,8 +245,8 @@ namespace osu.Game.Database var scoreProcessor = ruleset.CreateScoreProcessor(); // warning: ordering is important here - both total score and ranks are dependent on accuracy! - score.Accuracy = computeAccuracy(score, scoreProcessor); - score.Rank = computeRank(score, scoreProcessor); + score.Accuracy = ComputeAccuracy(score, scoreProcessor); + score.Rank = ComputeRank(score, scoreProcessor); (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, beatmap); } @@ -269,8 +269,8 @@ namespace osu.Game.Database var scoreProcessor = ruleset.CreateScoreProcessor(); // warning: ordering is important here - both total score and ranks are dependent on accuracy! - score.Accuracy = computeAccuracy(score, scoreProcessor); - score.Rank = computeRank(score, scoreProcessor); + score.Accuracy = ComputeAccuracy(score, scoreProcessor); + score.Rank = ComputeRank(score, scoreProcessor); (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes); } @@ -313,7 +313,8 @@ namespace osu.Game.Database /// The beatmap difficulty. /// The legacy scoring attributes for the beatmap which the score was set on. /// The standardised total score. - private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) + private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, + LegacyScoreAttributes attributes) { if (!score.IsLegacyScore) return (score.TotalScoreWithoutMods, score.TotalScore); @@ -620,24 +621,31 @@ namespace osu.Game.Database } } - private static double computeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + public static double ComputeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + => ComputeAccuracy(scoreInfo.Statistics, scoreInfo.MaximumStatistics, scoreProcessor); + + public static double ComputeAccuracy(IReadOnlyDictionary statistics, IReadOnlyDictionary maximumStatistics, ScoreProcessor scoreProcessor) { - int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()) - .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); - int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) - .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + int baseScore = statistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + int maxBaseScore = maximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore; } - public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => computeRank(scoreInfo, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor()); + public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => + ComputeRank(scoreInfo.Accuracy, scoreInfo.Statistics, scoreInfo.Mods, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor()); - private static ScoreRank computeRank(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + public static ScoreRank ComputeRank(ScoreInfo scoreInfo, ScoreProcessor processor) => + ComputeRank(scoreInfo.Accuracy, scoreInfo.Statistics, scoreInfo.Mods, processor); + + public static ScoreRank ComputeRank(double accuracy, IReadOnlyDictionary statistics, IList mods, ScoreProcessor scoreProcessor) { - var rank = scoreProcessor.RankFromScore(scoreInfo.Accuracy, scoreInfo.Statistics); + var rank = scoreProcessor.RankFromScore(accuracy, statistics); - foreach (var mod in scoreInfo.Mods.OfType()) - rank = mod.AdjustRank(rank, scoreInfo.Accuracy); + foreach (var mod in mods.OfType()) + rank = mod.AdjustRank(rank, accuracy); return rank; } diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index eef9b63b62..ec6b5ac6de 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -9,6 +9,7 @@ using osu.Game.IO; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Extensions @@ -164,5 +165,20 @@ namespace osu.Game.Extensions /// that function does not have per-platform considerations (and is only made to work on windows). /// public static string GetValidFilename(this string filename) => invalid_filename_chars.Replace(filename, "_"); + + public static bool RequiresSupporter(this BeatmapLeaderboardScope scope, bool filterMods) + { + switch (scope) + { + case BeatmapLeaderboardScope.Local: + return false; + + case BeatmapLeaderboardScope.Country: + case BeatmapLeaderboardScope.Friend: + return true; + } + + return filterMods; + } } } diff --git a/osu.Game/Extensions/StringDehumanizeExtensions.cs b/osu.Game/Extensions/StringDehumanizeExtensions.cs index 6f0d7622d3..5993f83b55 100644 --- a/osu.Game/Extensions/StringDehumanizeExtensions.cs +++ b/osu.Game/Extensions/StringDehumanizeExtensions.cs @@ -60,7 +60,7 @@ namespace osu.Game.Extensions public static string ToCamelCase(this string input) { string word = input.ToPascalCase(); - return word.Length > 0 ? word.Substring(0, 1).ToLowerInvariant() + word.Substring(1) : word; + return word.Length > 0 ? char.ToLowerInvariant(word[0]) + word.Substring(1) : word; } /// diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index 6f6febb646..b4be330f9c 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -28,7 +28,6 @@ namespace osu.Game.Graphics.Backgrounds [Resolved] private IAPIProvider api { get; set; } - private readonly IBindable apiState = new Bindable(); private Bindable seasonalBackgroundMode; private Bindable seasonalBackgrounds; @@ -47,13 +46,12 @@ namespace osu.Game.Graphics.Backgrounds SeasonalBackgroundChanged?.Invoke(); }); - apiState.BindTo(api.State); - apiState.BindValueChanged(fetchSeasonalBackgrounds, true); + fetchSeasonalBackgrounds(); } - private void fetchSeasonalBackgrounds(ValueChangedEvent stateChanged) + private void fetchSeasonalBackgrounds() { - if (seasonalBackgrounds.Value != null || stateChanged.NewValue != APIState.Online) + if (seasonalBackgrounds.Value != null) return; var request = new GetSeasonalBackgroundsRequest(); diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index e877915fac..d22aa197bb 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -127,8 +127,6 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode); - if (CreateNewTriangles) addTriangles(false); @@ -138,6 +136,10 @@ namespace osu.Game.Graphics.Backgrounds : 1; float elapsedSeconds = (float)Time.Elapsed / 1000; + + if (elapsedSeconds == 0) + return; + // Since position is relative, the velocity needs to scale inversely with DrawHeight. // Since we will later multiply by the scale of individual triangles we normalize by // dividing by triangleScale. @@ -157,6 +159,8 @@ namespace osu.Game.Graphics.Backgrounds if (bottomPos < 0) parts.RemoveAt(i); } + + Invalidate(Invalidation.DrawNode); } /// @@ -183,8 +187,13 @@ namespace osu.Game.Graphics.Backgrounds int currentCount = parts.Count; + if (AimCount - currentCount == 0) + return; + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); + + Invalidate(Invalidation.DrawNode); } private TriangleParticle createTriangle(bool randomY) diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs index 4143a6d76d..358e859cc8 100644 --- a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -91,12 +91,14 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode); - if (CreateNewTriangles) addTriangles(false); float elapsedSeconds = (float)Time.Elapsed / 1000; + + if (elapsedSeconds == 0) + return; + // Since position is relative, the velocity needs to scale inversely with DrawHeight. float movedDistance = -elapsedSeconds * Velocity * base_velocity / DrawHeight; @@ -112,6 +114,8 @@ namespace osu.Game.Graphics.Backgrounds if (bottomPos < 0) parts.RemoveAt(i); } + + Invalidate(Invalidation.DrawNode); } /// @@ -138,8 +142,13 @@ namespace osu.Game.Graphics.Backgrounds int currentCount = parts.Count; + if (AimCount - currentCount == 0) + return; + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); + + Invalidate(Invalidation.DrawNode); } private TriangleParticle createTriangle(bool randomY) diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 7210371ebf..4331b91e61 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -73,6 +73,16 @@ namespace osu.Game.Graphics.Containers /// protected bool IsBeatSyncedWithTrack { get; private set; } + /// + /// The most valid timing point, updated every frame. + /// + protected TimingControlPoint TimingPoint { get; private set; } = TimingControlPoint.DEFAULT; + + /// + /// The most valid effect point, updated every frame. + /// + protected EffectControlPoint EffectPoint { get; private set; } = EffectControlPoint.DEFAULT; + [Resolved] protected IBeatSyncProvider BeatSyncSource { get; private set; } = null!; @@ -82,9 +92,6 @@ namespace osu.Game.Graphics.Containers protected override void Update() { - TimingControlPoint timingPoint; - EffectControlPoint effectPoint; - IsBeatSyncedWithTrack = BeatSyncSource.Clock.IsRunning; double currentTrackTime; @@ -102,8 +109,8 @@ namespace osu.Game.Graphics.Containers currentTrackTime = BeatSyncSource.Clock.CurrentTime + early; - timingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; - effectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; + TimingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; + EffectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; } else { @@ -111,28 +118,28 @@ namespace osu.Game.Graphics.Containers // we still want to show an idle animation, so use this container's time instead. currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds; - timingPoint = TimingControlPoint.DEFAULT; - effectPoint = EffectControlPoint.DEFAULT; + TimingPoint = TimingControlPoint.DEFAULT; + EffectPoint = EffectControlPoint.DEFAULT; } - double beatLength = timingPoint.BeatLength / Divisor; + double beatLength = TimingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) beatLength *= 2; - int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (timingPoint.OmitFirstBarLine ? 1 : 0); + int beatIndex = (int)((currentTrackTime - TimingPoint.Time) / beatLength) - (TimingPoint.OmitFirstBarLine ? 1 : 0); // The beats before the start of the first control point are off by 1, this should do the trick - if (currentTrackTime < timingPoint.Time) + if (currentTrackTime < TimingPoint.Time) beatIndex--; - TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; + TimeUntilNextBeat = (TimingPoint.Time - currentTrackTime) % beatLength; if (TimeUntilNextBeat <= 0) TimeUntilNextBeat += beatLength; TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (ReferenceEquals(timingPoint, lastTimingPoint) && beatIndex == lastBeat) + if (ReferenceEquals(TimingPoint, lastTimingPoint) && beatIndex == lastBeat) return; // as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat. @@ -140,13 +147,13 @@ namespace osu.Game.Graphics.Containers if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) { using (BeginDelayedSequence(-TimeSinceLastBeat)) - OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.CurrentAmplitudes); + OnNewBeat(beatIndex, TimingPoint, EffectPoint, BeatSyncSource.CurrentAmplitudes); } lastBeat = beatIndex; - lastTimingPoint = timingPoint; + lastTimingPoint = TimingPoint; - IsKiaiTime = effectPoint.KiaiMode; + IsKiaiTime = EffectPoint.KiaiMode; } } } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index aa72996fff..6022ea6bd6 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -134,9 +134,14 @@ namespace osu.Game.Graphics.Containers protected virtual DrawableLinkCompiler CreateLinkCompiler(ITextPart textPart) => new DrawableLinkCompiler(textPart); - // We want the compilers to always be visible no matter where they are, so RelativeSizeAxes is used. - // However due to https://github.com/ppy/osu-framework/issues/2073, it's possible for the compilers to be relative size in the flow's auto-size axes - an unsupported operation. - // Since the compilers don't display any content and don't affect the layout, it's simplest to exclude them from the flow. - public override IEnumerable FlowingChildren => base.FlowingChildren.Where(c => !(c is DrawableLinkCompiler)); + protected override FillFlowContainer CreateFlow() => new LinkFlow(); + + private partial class LinkFlow : InnerFlow + { + // We want the compilers to always be visible no matter where they are, so RelativeSizeAxes is used. + // However due to https://github.com/ppy/osu-framework/issues/2073, it's possible for the compilers to be relative size in the flow's auto-size axes - an unsupported operation. + // Since the compilers don't display any content and don't affect the layout, it's simplest to exclude them from the flow. + public override IEnumerable FlowingChildren => base.FlowingChildren.Where(c => !(c is DrawableLinkCompiler)); + } } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs index 10207dd389..340e59dd91 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.Containers.Markdown public LocalisableString TooltipText { get; } public OsuMarkdownImage(LinkInline linkInline) - : base(linkInline.Url) + : base($"https://osu.ppy.sh/media-url?url={linkInline.Url}") { TooltipText = linkInline.Title; } diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index 3b5e48d23e..e396eb6ec9 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -15,9 +15,11 @@ namespace osu.Game.Graphics.Containers { protected const float FADE_DURATION = 500; - protected Color4 HoverColour; + public Color4? HoverColour { get; set; } + private Color4 fallbackHoverColour; - protected Color4 IdleColour = Color4.White; + public Color4? IdleColour { get; set; } + private Color4 fallbackIdleColour; protected virtual IEnumerable EffectTargets => new[] { Content }; @@ -67,18 +69,18 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load(OsuColour colours) { - if (HoverColour == default) - HoverColour = colours.Yellow; + fallbackHoverColour = colours.Yellow; + fallbackIdleColour = Color4.White; } protected override void LoadComplete() { base.LoadComplete(); - EffectTargets.ForEach(d => d.FadeColour(IdleColour)); + EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour)); } - private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour, FADE_DURATION, Easing.OutQuint)); + private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour ?? fallbackHoverColour, FADE_DURATION, Easing.OutQuint)); - private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour, FADE_DURATION, Easing.OutQuint)); + private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour, FADE_DURATION, Easing.OutQuint)); } } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index a3cd5a4902..43a42eae57 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -26,26 +26,12 @@ namespace osu.Game.Graphics.Containers } } - public partial class OsuScrollContainer : ScrollContainer where T : Drawable + public partial class OsuScrollContainer : ScrollContainer + where T : Drawable { public const float SCROLL_BAR_WIDTH = 10; public const float SCROLL_BAR_PADDING = 3; - /// - /// Allows controlling the scroll bar from any position in the container using the right mouse button. - /// Uses the value of to smoothly scroll to the dragged location. - /// - public bool RightMouseScrollbar; - - /// - /// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02. - /// - public double DistanceDecayOnRightMouseScrollbar = 0.02; - - private bool rightMouseDragging; - - protected override bool IsDragging => base.IsDragging || rightMouseDragging; - public OsuScrollContainer(Direction scrollDirection = Direction.Vertical) : base(scrollDirection) { @@ -59,11 +45,11 @@ namespace osu.Game.Graphics.Containers /// An added amount to scroll beyond the requirement to bring the target into view. public void ScrollIntoView(Drawable d, bool animated = true, float extraScroll = 0) { - float childPos0 = GetChildPosInContent(d); - float childPos1 = GetChildPosInContent(d, d.DrawSize); + double childPos0 = GetChildPosInContent(d); + double childPos1 = GetChildPosInContent(d, d.DrawSize); - float minPos = Math.Min(childPos0, childPos1); - float maxPos = Math.Max(childPos0, childPos1); + double minPos = Math.Min(childPos0, childPos1); + double maxPos = Math.Max(childPos0, childPos1); if (minPos < Current || (minPos > Current && d.DrawSize[ScrollDim] > DisplayableContent)) ScrollTo(minPos - extraScroll, animated); @@ -71,50 +57,6 @@ namespace osu.Game.Graphics.Containers ScrollTo(maxPos - DisplayableContent + extraScroll, animated); } - protected override bool OnMouseDown(MouseDownEvent e) - { - if (shouldPerformRightMouseScroll(e)) - { - ScrollFromMouseEvent(e); - return true; - } - - return base.OnMouseDown(e); - } - - protected override void OnDrag(DragEvent e) - { - if (rightMouseDragging) - { - ScrollFromMouseEvent(e); - return; - } - - base.OnDrag(e); - } - - protected override bool OnDragStart(DragStartEvent e) - { - if (shouldPerformRightMouseScroll(e)) - { - rightMouseDragging = true; - return true; - } - - return base.OnDragStart(e); - } - - protected override void OnDragEnd(DragEndEvent e) - { - if (rightMouseDragging) - { - rightMouseDragging = false; - return; - } - - base.OnDragEnd(e); - } - protected override bool OnScroll(ScrollEvent e) { // allow for controlling volume when alt is held. @@ -124,15 +66,22 @@ namespace osu.Game.Graphics.Containers return base.OnScroll(e); } - protected virtual void ScrollFromMouseEvent(MouseEvent e) + #region Absolute scrolling + + /// + /// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02. + /// + public double DistanceDecayOnAbsoluteScroll = 0.02; + + protected virtual void ScrollToAbsolutePosition(Vector2 screenSpacePosition) { - float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim]); + float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(screenSpacePosition)[ScrollDim]); float scrollbarCentreOffset = FromScrollbarPosition(Scrollbar.DrawHeight) * 0.5f; - ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnRightMouseScrollbar); + ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnAbsoluteScroll); } - private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; + #endregion protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); diff --git a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs index d3bbc2e80b..8da8b7ed7d 100644 --- a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs @@ -21,8 +21,18 @@ namespace osu.Game.Graphics.Containers protected override SpriteText CreateSpriteText() => new OsuSpriteText(); - public ITextPart AddArbitraryDrawable(Drawable drawable) => AddPart(new TextPartManual(drawable.Yield())); + public ITextPart AddArbitraryDrawable(Drawable drawable) => AddPart(new TextPartManual(new ArbitraryDrawableWrapper { Child = drawable }.Yield())); public ITextPart AddIcon(IconUsage icon, Action creationParameters = null) => AddText(icon.Icon.ToString(), creationParameters); + + private partial class ArbitraryDrawableWrapper : Container, IHasLineBaseHeight + { + public float LineBaseHeight => (Child as IHasLineBaseHeight)?.LineBaseHeight ?? DrawHeight; + + public ArbitraryDrawableWrapper() + { + AutoSizeAxes = Axes.Both; + } + } } } diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index c47aba2f0c..9d2a1c16af 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -26,17 +24,17 @@ namespace osu.Game.Graphics.Containers { internal const float TRANSITION_DURATION = 500; - private Bindable sizeX; - private Bindable sizeY; - private Bindable posX; - private Bindable posY; - private Bindable applySafeAreaPadding; + private Bindable sizeX = null!; + private Bindable sizeY = null!; + private Bindable posX = null!; + private Bindable posY = null!; + private Bindable applySafeAreaPadding = null!; - private Bindable safeAreaPadding; + private Bindable safeAreaPadding = null!; private readonly ScalingMode? targetMode; - private Bindable scalingMode; + private Bindable scalingMode = null!; private readonly Container content; protected override Container Content => content; @@ -45,9 +43,9 @@ namespace osu.Game.Graphics.Containers private readonly Container sizableContainer; - private BackgroundScreenStack backgroundStack; + private BackgroundScreenStack? backgroundStack; - private Bindable scalingMenuBackgroundDim; + private Bindable scalingMenuBackgroundDim = null!; private RectangleF? customRect; private bool customRectIsRelativePosition; @@ -88,7 +86,8 @@ namespace osu.Game.Graphics.Containers public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer { private readonly bool applyUIScale; - private Bindable uiScale; + + private Bindable? uiScale; protected float CurrentScale { get; private set; } = 1; @@ -99,6 +98,9 @@ namespace osu.Game.Graphics.Containers this.applyUIScale = applyUIScale; } + [Resolved(canBeNull: true)] + private OsuGame? game { get; set; } + [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig) { @@ -111,6 +113,8 @@ namespace osu.Game.Graphics.Containers protected override void Update() { + if (game != null) + TargetDrawSize = game.ScalingContainerTargetDrawSize; Scale = new Vector2(CurrentScale); Size = new Vector2(1 / CurrentScale); @@ -233,13 +237,13 @@ namespace osu.Game.Graphics.Containers private partial class SizeableAlwaysInputContainer : Container { [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [Resolved] - private ISafeArea safeArea { get; set; } + private ISafeArea safeArea { get; set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; private readonly bool confineHostCursor; private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 9f41c4eff2..828fc9704c 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -208,7 +208,7 @@ namespace osu.Game.Graphics.Containers private float getScrollTargetForDrawable(Drawable target) { // implementation similar to ScrollIntoView but a bit more nuanced. - return scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre; + return (float)(scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre); } public void ScrollToTop() => scrollContainer.ScrollTo(0); @@ -259,7 +259,7 @@ namespace osu.Game.Graphics.Containers updateSectionsMargin(); } - float currentScroll = scrollContainer.Current; + float currentScroll = (float)scrollContainer.Current; if (currentScroll != lastKnownScroll) { diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 354a57b7d2..ab17c3f9e3 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Input.Events; +using osuTK; namespace osu.Game.Graphics.Containers { @@ -35,7 +35,7 @@ namespace osu.Game.Graphics.Containers { } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { UserScrolling = true; base.OnUserScroll(value, animated, distanceDecay); @@ -47,13 +47,13 @@ namespace osu.Game.Graphics.Containers base.ScrollIntoView(target, animated); } - protected override void ScrollFromMouseEvent(MouseEvent e) + protected override void ScrollToAbsolutePosition(Vector2 screenSpacePosition) { UserScrolling = true; - base.ScrollFromMouseEvent(e); + base.ScrollToAbsolutePosition(screenSpacePosition); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { UserScrolling = false; base.ScrollTo(value, animated, distanceDecay); diff --git a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs index 7b21a413f7..3180661b0c 100644 --- a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs @@ -11,16 +11,8 @@ namespace osu.Game.Graphics.Cursor [Cached(typeof(OsuContextMenuContainer))] public partial class OsuContextMenuContainer : ContextMenuContainer { - [Cached] - private OsuContextMenuSamples samples = new OsuContextMenuSamples(); - private OsuContextMenu menu = null!; - public OsuContextMenuContainer() - { - AddInternal(samples); - } - protected override Menu CreateMenu() => menu = new OsuContextMenu(true); public void CloseMenu() diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index c479d0cfe4..2c43876fb2 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -195,6 +195,27 @@ namespace osu.Game.Graphics } } + /// + /// Retrieves the accent colour representing a 's current status. + /// + public Color4 ForRoomStatus(Room room) + { + if (room.HasEnded) + return YellowDarker; + + switch (room.Status) + { + case RoomStatus.Playing: + return Purple; + + default: + if (room.HasPassword) + return GreenDark; + + return GreenLight; + } + } + /// /// Retrieves colour for a . /// See https://www.figma.com/file/YHWhp9wZ089YXgB7pe6L1k/Tier-Colours diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index b3ffd15816..e5a4e807b5 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -89,7 +89,7 @@ namespace osu.Game.Graphics.UserInterface { if (Link == null) return; - game?.CopyUrlToClipboard(Link); + game?.CopyToClipboard(Link); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index 5b0fbc693e..62eb765cc8 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -23,6 +23,9 @@ namespace osu.Game.Graphics.UserInterface DialogCancel, [Description("dialog-ok")] - DialogOk + DialogOk, + + [Description("menu-open")] + MenuOpen, } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index 96797e5d01..72ffde3574 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -12,17 +12,11 @@ namespace osu.Game.Graphics.UserInterface { public partial class OsuContextMenu : OsuMenu { - private const int fade_duration = 250; - [Resolved] - private OsuContextMenuSamples samples { get; set; } = null!; + private OsuMenuSamples menuSamples { get; set; } = null!; - // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. - private bool wasOpened; - private readonly bool playClickSample; - - public OsuContextMenu(bool playClickSample = false) - : base(Direction.Vertical) + public OsuContextMenu(bool playSamples) + : base(Direction.Vertical, topLevelMenu: false, playSamples) { MaskingContainer.CornerRadius = 5; MaskingContainer.EdgeEffect = new EdgeEffectParameters @@ -35,8 +29,6 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; MaxHeight = 250; - - this.playClickSample = playClickSample; } [BackgroundDependencyLoader] @@ -47,27 +39,12 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { - this.FadeIn(fade_duration, Easing.OutQuint); + if (PlaySamples && !WasOpened) + menuSamples.PlayClickSample(); - if (playClickSample) - samples.PlayClickSample(); - - if (!wasOpened) - samples.PlayOpenSample(); - - wasOpened = true; + base.AnimateOpen(); } - protected override void AnimateClose() - { - this.FadeOut(fade_duration, Easing.OutQuint); - - if (wasOpened) - samples.PlayCloseSample(); - - wasOpened = false; - } - - protected override Menu CreateSubMenu() => new OsuContextMenu(); + protected override Menu CreateSubMenu() => new OsuContextMenu(false); // sub menu samples are handled by OsuMenu.OnSubmenuOpen. } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs deleted file mode 100644 index 6d7543c472..0000000000 --- a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Extensions; -using osu.Framework.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - public partial class OsuContextMenuSamples : Component - { - private Sample sampleClick; - private Sample sampleOpen; - private Sample sampleClose; - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleClick = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); - sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); - sampleClose = audio.Samples.Get(@"UI/dropdown-close"); - } - - public void PlayClickSample() => Scheduler.AddOnce(playClickSample); - private void playClickSample() => sampleClick.Play(); - - public void PlayOpenSample() => Scheduler.AddOnce(playOpenSample); - private void playOpenSample() => sampleOpen.Play(); - - public void PlayCloseSample() => Scheduler.AddOnce(playCloseSample); - private void playCloseSample() => sampleClose.Play(); - } -} diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index dc42216c55..5a1fbaa3a4 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -53,7 +53,7 @@ namespace osu.Game.Graphics.UserInterface #region OsuDropdownMenu - protected partial class OsuDropdownMenu : DropdownMenu + public partial class OsuDropdownMenu : DropdownMenu { public override bool HandleNonPositionalInput => State == MenuState.Open; diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index 6e7dad2b5f..11d9000dfa 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -4,8 +4,6 @@ #nullable disable using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,26 +18,32 @@ namespace osu.Game.Graphics.UserInterface { public partial class OsuMenu : Menu { - private Sample sampleOpen; - private Sample sampleClose; + protected const double DELAY_BEFORE_FADE_OUT = 50; + protected const double FADE_DURATION = 280; // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. - private bool wasOpened; + protected bool WasOpened { get; private set; } + + public bool PlaySamples { get; } + + [Resolved] + private OsuMenuSamples menuSamples { get; set; } = null!; public OsuMenu(Direction direction, bool topLevelMenu = false) + : this(direction, topLevelMenu, playSamples: !topLevelMenu) + { + } + + protected OsuMenu(Direction direction, bool topLevelMenu, bool playSamples) : base(direction, topLevelMenu) { + PlaySamples = playSamples; BackgroundColour = Color4.Black.Opacity(0.5f); MaskingContainer.CornerRadius = 4; ItemsContainer.Padding = new MarginPadding(5); - } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); - sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + OnSubmenuOpen += _ => menuSamples?.PlaySubOpenSample(); } protected override void Update() @@ -63,20 +67,22 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { - if (!TopLevelMenu && !wasOpened) - sampleOpen?.Play(); + if (PlaySamples && !WasOpened) + menuSamples?.PlayOpenSample(); - this.FadeIn(300, Easing.OutQuint); - wasOpened = true; + WasOpened = true; + this.FadeIn(FADE_DURATION, Easing.OutQuint); } protected override void AnimateClose() { - if (!TopLevelMenu && wasOpened) - sampleClose?.Play(); + if (PlaySamples && WasOpened) + menuSamples?.PlayCloseSample(); - this.FadeOut(300, Easing.OutQuint); - wasOpened = false; + this.Delay(DELAY_BEFORE_FADE_OUT) + .FadeOut(FADE_DURATION, Easing.OutQuint); + + WasOpened = false; } protected override void UpdateSize(Vector2 newSize) @@ -84,12 +90,21 @@ namespace osu.Game.Graphics.UserInterface if (Direction == Direction.Vertical) { Width = newSize.X; - this.ResizeHeightTo(newSize.Y, 300, Easing.OutQuint); + + if (newSize.Y > 0) + this.ResizeHeightTo(newSize.Y, 300, Easing.OutQuint); + else + // Delay until the fade out finishes from AnimateClose. + this.Delay(DELAY_BEFORE_FADE_OUT + FADE_DURATION).ResizeHeightTo(0); } else { Height = newSize.Y; - this.ResizeWidthTo(newSize.X, 300, Easing.OutQuint); + if (newSize.X > 0) + this.ResizeWidthTo(newSize.X, 300, Easing.OutQuint); + else + // Delay until the fade out finishes from AnimateClose. + this.Delay(DELAY_BEFORE_FADE_OUT + FADE_DURATION).ResizeWidthTo(0); } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs new file mode 100644 index 0000000000..779671b6ad --- /dev/null +++ b/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class OsuMenuSamples : Component + { + private Sample sampleClick; + private Sample sampleOpen; + private Sample sampleSubOpen; + private Sample sampleClose; + + private bool triggerOpen; + private bool triggerSubOpen; + private bool triggerClose; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleClick = audio.Samples.Get(@"UI/menu-open-select"); + sampleOpen = audio.Samples.Get(@"UI/menu-open"); + sampleSubOpen = audio.Samples.Get(@"UI/menu-sub-open"); + sampleClose = audio.Samples.Get(@"UI/menu-close"); + } + + public void PlayClickSample() + { + Scheduler.AddOnce(playClickSample); + } + + public void PlayOpenSample() + { + triggerOpen = true; + Scheduler.AddOnce(resolvePlayback); + } + + public void PlaySubOpenSample() + { + triggerSubOpen = true; + Scheduler.AddOnce(resolvePlayback); + } + + public void PlayCloseSample() + { + triggerClose = true; + Scheduler.AddOnce(resolvePlayback); + } + + private void playClickSample() => sampleClick.Play(); + + private void resolvePlayback() + { + if (triggerSubOpen) + sampleSubOpen?.Play(); + else if (triggerOpen) + sampleOpen?.Play(); + else if (triggerClose) + sampleClose?.Play(); + + triggerOpen = triggerSubOpen = triggerClose = false; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index db4b7b2ab3..991ab6798f 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,17 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Input; + namespace osu.Game.Graphics.UserInterface { public partial class OsuNumberBox : OsuTextBox { - protected override bool AllowIme => false; - public OsuNumberBox() { + InputProperties = new TextInputProperties(TextInputType.Number, false); + SelectAllOnFocus = true; } - - protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); } } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 0be7b4dc48..143962542d 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,7 +18,7 @@ using osu.Game.Localisation; namespace osu.Game.Graphics.UserInterface { - public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging + public partial class OsuPasswordTextBox : OsuTextBox { protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { @@ -28,12 +28,6 @@ namespace osu.Game.Graphics.UserInterface protected override bool AllowUniqueCharacterSamples => false; - protected override bool AllowClipboardExport => false; - - protected override bool AllowWordNavigation => false; - - protected override bool AllowIme => false; - private readonly CapsWarning warning; [Resolved] @@ -41,6 +35,8 @@ namespace osu.Game.Graphics.UserInterface public OsuPasswordTextBox() { + InputProperties = new TextInputProperties(TextInputType.Password, false); + Add(warning = new CapsWarning { Size = new Vector2(20), diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 334fe343ae..4b52ac4a3a 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -18,6 +18,8 @@ namespace osu.Game.Graphics.UserInterface public abstract partial class OsuSliderBar : SliderBar, IHasTooltip where T : struct, INumber, IMinMaxValue { + public override bool AcceptsFocus => !Current.Disabled; + public bool PlaySamplesOnAdjust { get; set; } = true; /// diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs index 422c2ca4a3..acf10ce827 100644 --- a/osu.Game/Graphics/UserInterface/RangeSlider.cs +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -162,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface protected partial class BoundSlider : RoundedSliderBar { + public override bool AcceptsFocus => false; + public new Nub Nub => base.Nub; public string? DefaultString; diff --git a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs index aeab7c34b2..9a0183da64 100644 --- a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Overlays; using Vector2 = osuTK.Vector2; @@ -52,10 +53,21 @@ namespace osu.Game.Graphics.UserInterface } } + /// + /// The action to use to reset the value of to the default. + /// Triggered on double click. + /// + public Action ResetToDefault { get; internal set; } + public RoundedSliderBar() { Height = Nub.HEIGHT; RangePadding = Nub.DEFAULT_EXPANDED_SIZE / 2; + ResetToDefault = () => + { + if (!Current.Disabled) + Current.SetDefault(); + }; Children = new Drawable[] { new Container @@ -102,11 +114,7 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, Current = { Value = true }, - OnDoubleClicked = () => - { - if (!Current.Disabled) - Current.SetDefault(); - }, + OnDoubleClicked = () => ResetToDefault.Invoke(), }, }, hoverClickSounds = new HoverClickSounds() diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs new file mode 100644 index 0000000000..1c5d4b5d80 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -0,0 +1,189 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormButton : CompositeDrawable + { + /// + /// Caption describing this button, displayed on the left of it. + /// + public LocalisableString Caption { get; init; } + + public LocalisableString ButtonText { get; init; } + + public Action? Action { get; init; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = 50; + + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = 9, + Right = 5, + Vertical = 5, + }, + Children = new Drawable[] + { + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.45f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Caption, + }, + new Button + { + Action = Action, + Text = ButtonText, + RelativeSizeAxes = ButtonText == default ? Axes.None : Axes.X, + Width = ButtonText == default ? 90 : 0.45f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }, + }, + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void updateState() + { + BorderThickness = IsHovered ? 2 : 0; + + if (IsHovered) + BorderColour = colourProvider.Light4; + } + + public partial class Button : OsuButton + { + private TrianglesV2? triangles { get; set; } + + protected override float HoverLayerFinalAlpha => 0; + + private Color4? triangleGradientSecondColour; + + public override Color4 BackgroundColour + { + get => base.BackgroundColour; + set + { + base.BackgroundColour = value; + triangleGradientSecondColour = BackgroundColour.Lighten(0.2f); + updateColours(); + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColourProvider) + { + DefaultBackgroundColour = overlayColourProvider.Colour3; + triangleGradientSecondColour ??= overlayColourProvider.Colour1; + + if (Text == default) + { + Add(new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(16), + Shadow = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Content.CornerRadius = 4; + + Add(triangles = new TrianglesV2 + { + Thickness = 0.02f, + SpawnRatio = 0.6f, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }); + + updateColours(); + } + + private void updateColours() + { + if (triangles == null) + return; + + Debug.Assert(triangleGradientSecondColour != null); + + triangles.Colour = ColourInfo.GradientVertical(triangleGradientSecondColour.Value, BackgroundColour); + } + + protected override bool OnHover(HoverEvent e) + { + Debug.Assert(triangleGradientSecondColour != null); + + Background.FadeColour(triangleGradientSecondColour.Value, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Background.FadeColour(BackgroundColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs index fad58841e3..a0348fa27a 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs @@ -31,9 +31,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 public LocalisableString Caption { get; init; } public LocalisableString HintText { get; init; } + public BindableBool CanAdd { get; } = new BindableBool(true); + private Box background = null!; private FormFieldCaption caption = null!; private FillFlowContainer flow = null!; + private RoundedButton addButton = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -47,8 +50,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 Masking = true; CornerRadius = 5; - RoundedButton button; - InternalChildren = new Drawable[] { background = new Box @@ -76,7 +77,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, Spacing = new Vector2(5), - Child = button = new RoundedButton + Child = addButton = new RoundedButton { Action = addNewColour, Size = new Vector2(70), @@ -87,7 +88,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, }; - flow.SetLayoutPosition(button, float.MaxValue); + flow.SetLayoutPosition(addButton, float.MaxValue); } protected override void LoadComplete() @@ -99,6 +100,19 @@ namespace osu.Game.Graphics.UserInterfaceV2 if (args.Action != NotifyCollectionChangedAction.Replace) updateColours(); }, true); + CanAdd.BindValueChanged(canAdd => + { + if (canAdd.NewValue) + { + addButton.Enabled.Value = true; + addButton.TooltipText = string.Empty; + } + else + { + addButton.Enabled.Value = false; + addButton.TooltipText = "Maximum combo colours reached"; + } + }, true); updateState(); } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs index 81023417a5..5fdf453fc4 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -242,20 +242,26 @@ namespace osu.Game.Graphics.UserInterfaceV2 Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); + protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => new FileChooserPopover(handledExtensions, current, chooserPath); + public Popover GetPopover() { - var popover = new FileChooserPopover(handledExtensions, Current, initialChooserPath); + var popover = CreatePopover(handledExtensions, Current, initialChooserPath); popoverState.UnbindBindings(); popoverState.BindTo(popover.State); return popover; } - private partial class FileChooserPopover : OsuPopover + protected partial class FileChooserPopover : OsuPopover { protected override string PopInSampleName => "UI/overlay-big-pop-in"; protected override string PopOutSampleName => "UI/overlay-big-pop-out"; - public FileChooserPopover(string[] handledExtensions, Bindable currentFile, string? chooserPath) + private readonly Bindable current = new Bindable(); + + protected OsuFileSelector FileSelector; + + public FileChooserPopover(string[] handledExtensions, Bindable current, string? chooserPath) : base(false) { Child = new Container @@ -264,12 +270,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 // simplest solution to avoid underlying text to bleed through the bottom border // https://github.com/ppy/osu/pull/30005#issuecomment-2378884430 Padding = new MarginPadding { Bottom = 1 }, - Child = new OsuFileSelector(chooserPath, handledExtensions) + Child = FileSelector = new OsuFileSelector(chooserPath, handledExtensions) { RelativeSizeAxes = Axes.Both, - CurrentFile = { BindTarget = currentFile } }, }; + + this.current.BindTo(current); } [BackgroundDependencyLoader] @@ -292,6 +299,19 @@ namespace osu.Game.Graphics.UserInterfaceV2 } }); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + FileSelector.CurrentFile.ValueChanged += f => + { + if (f.NewValue != null) + OnFileSelected(f.NewValue); + }; + } + + protected virtual void OnFileSelected(FileInfo file) => current.Value = file; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs index c3256e0038..b739155a36 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs @@ -1,26 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Globalization; +using osu.Framework.Input; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class FormNumberBox : FormTextBox { - public bool AllowDecimals { get; init; } + private readonly bool allowDecimals; - internal override InnerTextBox CreateTextBox() => new InnerNumberBox + public FormNumberBox(bool allowDecimals = false) + { + this.allowDecimals = allowDecimals; + } + + internal override InnerTextBox CreateTextBox() => new InnerNumberBox(allowDecimals) { - AllowDecimals = AllowDecimals, SelectAllOnFocus = true, }; internal partial class InnerNumberBox : InnerTextBox { - public bool AllowDecimals { get; init; } - - protected override bool CanAddCharacter(char character) - => char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character)); + public InnerNumberBox(bool allowDecimals) + { + InputProperties = new TextInputProperties(allowDecimals ? TextInputType.Decimal : TextInputType.Number, false); + } } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 532423876e..4e43b133c7 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -119,7 +119,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Caption = Caption, TooltipText = HintText, }, - textBox = new FormNumberBox.InnerNumberBox + textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -127,7 +127,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 Width = 0.5f, CommitOnFocusLost = true, SelectAllOnFocus = true, - AllowDecimals = true, OnInputError = () => { flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index c7b559d9ed..addea5c4a9 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2.FileSelection; using osu.Game.Overlays; +using osu.Game.Utils; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -96,24 +97,18 @@ namespace osu.Game.Graphics.UserInterfaceV2 { get { - if (OsuGameBase.VIDEO_EXTENSIONS.Contains(File.Extension.ToLowerInvariant())) + string extension = File.Extension.ToLowerInvariant(); + + if (SupportedExtensions.VIDEO_EXTENSIONS.Contains(extension)) return FontAwesome.Regular.FileVideo; - switch (File.Extension) - { - case @".ogg": - case @".mp3": - case @".wav": - return FontAwesome.Regular.FileAudio; + if (SupportedExtensions.AUDIO_EXTENSIONS.Contains(extension)) + return FontAwesome.Regular.FileAudio; - case @".jpg": - case @".jpeg": - case @".png": - return FontAwesome.Regular.FileImage; + if (SupportedExtensions.IMAGE_EXTENSIONS.Contains(extension)) + return FontAwesome.Regular.FileImage; - default: - return FontAwesome.Regular.File; - } + return FontAwesome.Regular.File; } } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs index 9b4689958c..7abaca4092 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -14,7 +14,6 @@ using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osuTK; -using osuTK.Input; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -75,14 +74,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 samplePopOut?.Play(); } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Key == Key.Escape) - return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back). - - return base.OnKeyDown(e); - } - public virtual bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index 6aded3fe32..9b57ebb200 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Backgrounds; @@ -17,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class RoundedButton : OsuButton, IFilterable + public partial class RoundedButton : OsuButton, IFilterable, IHasTooltip { protected TrianglesV2? Triangles { get; private set; } @@ -107,5 +108,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } public bool FilteringActive { get; set; } + + public virtual LocalisableString TooltipText { get; set; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs new file mode 100644 index 0000000000..0b9c5f294c --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs @@ -0,0 +1,308 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class ShearedDropdown : Dropdown, IKeyBindingHandler + { + protected override DropdownHeader CreateHeader() => new ShearedDropdownHeader(); + + protected override DropdownMenu CreateMenu() => new ShearedDropdownMenu(); + + public ShearedDropdown(LocalisableString label) + { + if (Header is ShearedDropdownHeader osuHeader) + { + osuHeader.Dropdown = this; + osuHeader.LeftSideLabel = label; + } + } + + protected override void Update() + { + base.Update(); + + var header = (ShearedDropdownHeader)Header; + var menu = (ShearedDropdownMenu)Menu; + + menu.Padding = new MarginPadding { Left = header.LabelContainer.DrawWidth - 10f, Right = 6f }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) return false; + + if (e.Action == GlobalAction.Back) + return Back(); + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected partial class ShearedDropdownMenu : OsuDropdown.OsuDropdownMenu + { + private readonly Vector2 shear = new Vector2(OsuGame.SHEAR, 0); + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + public ShearedDropdownMenu() + { + Shear = shear; + Margin = new MarginPadding { Top = 5f }; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new ShearedMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + + public partial class ShearedMenuItem : DrawableOsuDropdownMenuItem + { + private readonly Vector2 shear = new Vector2(OsuGame.SHEAR, 0); + + public ShearedMenuItem(MenuItem item) + : base(item) + { + Foreground.Shear = -shear; + } + } + } + + public partial class ShearedDropdownHeader : DropdownHeader + { + private const float corner_radius = 5f; + + private LocalisableString label; + + protected override LocalisableString Label + { + get => label; + set + { + label = value; + valueText.Text = value; + } + } + + public LocalisableString LeftSideLabel + { + set => labelText.Text = value; + } + + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText valueText; + private readonly Box labelBox; + private readonly SpriteIcon chevron; + + public Container LabelContainer { get; } + + public ShearedDropdown Dropdown = null!; + private ShearedDropdownSearchBar searchBar = null!; + + private readonly Vector2 shear = new Vector2(OsuGame.SHEAR, 0); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ShearedDropdownHeader() + { + Shear = shear; + CornerRadius = corner_radius; + Masking = true; + + Foreground.Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new[] + { + LabelContainer = new Container + { + CornerRadius = corner_radius, + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + labelBox = new Box + { + RelativeSizeAxes = Axes.Both + }, + labelText = new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 10f, Vertical = 8f }, + Font = OsuFont.Torus.With(size: 16.8f, weight: FontWeight.SemiBold), + Shear = -shear, + }, + }, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10f }, + Shear = -shear, + Children = new Drawable[] + { + valueText = new TruncatingSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = 15f }, + Font = OsuFont.Torus.With(size: 16.8f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + chevron = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Y = 1f, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(10f), + } + }, + }, + } + } + }, + }; + + AddInternal(LabelContainer.CreateProxy()); + } + + [BackgroundDependencyLoader] + private void load() + { + labelBox.Colour = colourProvider.Background3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Dropdown.Menu.StateChanged += _ => updateChevron(); + SearchBar.State.ValueChanged += _ => updateColour(); + Enabled.BindValueChanged(_ => updateColour()); + updateColour(); + } + + protected override void Update() + { + base.Update(); + searchBar.Padding = new MarginPadding { Left = LabelContainer.DrawWidth }; + + // By limiting the width we avoid this box showing up as an outline around the drawables that are on top of it. + Background.Padding = new MarginPadding { Left = LabelContainer.DrawWidth - corner_radius }; + } + + protected override bool OnHover(HoverEvent e) + { + updateColour(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateColour(); + } + + private void updateColour() + { + bool hovered = Enabled.Value && IsHovered; + var hoveredColour = colourProvider.Light4; + var unhoveredColour = colourProvider.Background5; + + Colour = Color4.White; + Alpha = Enabled.Value ? 1 : 0.3f; + + if (SearchBar.State.Value == Visibility.Visible) + { + chevron.Colour = hovered ? hoveredColour.Lighten(0.5f) : Colour4.White; + Background.Colour = unhoveredColour; + } + else + { + chevron.Colour = Color4.White; + Background.Colour = hovered ? hoveredColour : unhoveredColour; + } + } + + private void updateChevron() + { + Debug.Assert(Dropdown != null); + bool open = Dropdown.Menu.State == MenuState.Open; + chevron.ScaleTo(open ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); + } + + protected override DropdownSearchBar CreateSearchBar() => searchBar = new ShearedDropdownSearchBar(); + + private partial class ShearedDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new DropdownSearchTextBox + { + FontSize = OsuFont.Default.Size, + }; + + private partial class DropdownSearchTextBox : OsuTextBox + { + private readonly Vector2 shear = new Vector2(OsuGame.SHEAR, 0); + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider) + { + TextContainer.Shear = -shear; + BackgroundUnfocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + BackgroundFocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + BorderThickness = 0; + } + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs index 50d8d763e1..2fbe3ae89b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -32,6 +32,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 set => slider.Current = value; } + public CompositeDrawable TabbableContentContainer + { + set => textBox.TabbableContentContainer = value; + } + private bool instantaneous; /// @@ -69,6 +74,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 textBox = new LabelledTextBox { Label = labelText, + SelectAllOnFocus = true, }, slider = new SettingsSlider { @@ -87,8 +93,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; - public bool SelectAll() => textBox.SelectAll(); - private bool updatingFromTextBox; private void textChanged(ValueChangedEvent change) diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index 6bb2a314e7..8b9ecc7462 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Text; using Microsoft.Toolkit.HighPerformance; +using osu.Framework.Extensions; using osu.Framework.IO.Stores; using SharpCompress.Archives.Zip; using SharpCompress.Common; @@ -54,12 +55,22 @@ namespace osu.Game.IO.Archives if (entry == null) return null; - var owner = MemoryAllocator.Default.Allocate((int)entry.Size); - using (Stream s = entry.OpenEntryStream()) - s.ReadExactly(owner.Memory.Span); + { + if (entry.Size > 0) + { + var owner = MemoryAllocator.Default.Allocate((int)entry.Size); + s.ReadExactly(owner.Memory.Span); + return new MemoryOwnerMemoryStream(owner); + } - return new MemoryOwnerMemoryStream(owner); + // due to a sharpcompress bug (https://github.com/adamhathcock/sharpcompress/issues/88), + // in rare instances the `ZipArchiveEntry` will not contain a correct `Size` but instead report 0. + // this would lead to the block above reading nothing, and the game basically seeing an archive full of empty files. + // since the bug is years old now, and this is a rather rare situation anyways (reported once in years), + // work around this locally by falling back to reading as many bytes as possible and using a standard non-pooled memory stream. + return new MemoryStream(s.ReadAllRemainingBytesToArray()); + } } public override void Dispose() diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index a936fa74da..27e1889c6a 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -61,6 +61,11 @@ namespace osu.Game.IO TryChangeToCustomStorage(out Error); } + /// + /// Returns the used for storing exported files. + /// + public virtual Storage GetExportStorage() => GetStorageForDirectory(@"exports"); + /// /// Resets the custom storage path, changing the target storage to the default location. /// diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index de25d3e30e..19ef6b8fe6 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -62,8 +62,12 @@ namespace osu.Game.IO.Serialization.Converters if (tok["$type"] == null) throw new JsonException("Expected $type token."); - string typeName = lookupTable[(int)tok["$type"]]; - var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull())!; + // Prevent instantiation of types that do not inherit the type targetted by this converter + Type type = Type.GetType(lookupTable[(int)tok["$type"]]).AsNonNull(); + if (!type.IsAssignableTo(typeof(T))) + continue; + + var instance = (T)Activator.CreateInstance(type)!; serializer.Populate(itemReader, instance); list.Add(instance); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 02ede0a2f8..6de2dabe2b 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -142,16 +142,20 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), - // Framework automatically converts wheel up/down to left/right when shift is held. - // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38. - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(InputKey.None, GlobalAction.EditorToggleMoveControl), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), new KeyBinding(new[] { InputKey.Control, InputKey.Right }, GlobalAction.EditorSeekToNextHitObject), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Left }, GlobalAction.EditorSeekToPreviousSamplePoint), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Right }, GlobalAction.EditorSeekToNextSamplePoint), + new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.EditorAddBookmark), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.B }, GlobalAction.EditorRemoveClosestBookmark), + new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark), + new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark), + new KeyBinding(new[] { InputKey.Control, InputKey.L }, GlobalAction.EditorDiscardUnsavedChanges), }; private static IEnumerable editorTestPlayKeyBindings => new[] @@ -202,6 +206,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.BackSpace, GlobalAction.DeselectAllMods), new KeyBinding(new[] { InputKey.Control, InputKey.Up }, GlobalAction.IncreaseModSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Down }, GlobalAction.DecreaseModSpeed), + new KeyBinding(InputKey.None, GlobalAction.AbsoluteScrollSongList), }; private static IEnumerable audioControlKeyBindings => new[] @@ -223,6 +228,10 @@ namespace osu.Game.Input.Bindings }; } + /// + /// IMPORTANT: New entries should always be added at the end of the enum, as key bindings are stored using the enum's numeric value and + /// changes in order would cause key bindings to get associated with the wrong action. + /// public enum GlobalAction { [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))] @@ -476,6 +485,27 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))] EditorCycleGridType, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorAddBookmark))] + EditorAddBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorRemoveClosestBookmark))] + EditorRemoveClosestBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousBookmark))] + EditorSeekToPreviousBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextBookmark))] + EditorSeekToNextBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))] + AbsoluteScrollSongList, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))] + EditorToggleMoveControl, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges))] + EditorDiscardUnsavedChanges, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/BeatmapOverlayStrings.cs b/osu.Game/Localisation/BeatmapOverlayStrings.cs index fc818f7596..f8122c1ef9 100644 --- a/osu.Game/Localisation/BeatmapOverlayStrings.cs +++ b/osu.Game/Localisation/BeatmapOverlayStrings.cs @@ -28,6 +28,11 @@ This includes content that may not be correctly licensed for osu! usage. Browse /// public static LocalisableString UserContentConfirmButtonText => new TranslatableString(getKey(@"understood"), @"I understand"); + /// + /// "Featured Artists are music artists who have collaborated with osu! to make a selection of their tracks available for use in beatmaps. For some osu! releases, we showcase only featured artist beatmaps to better support the surrounding ecosystem." + /// + public static LocalisableString FeaturedArtistsTooltip => new TranslatableString(getKey(@"featured_artists_disabled_tooltip"), @"Featured Artists are music artists who have collaborated with osu! to make a selection of their tracks available for use in beatmaps. For some osu! releases, we showcase only featured artist beatmaps to better support the surrounding ecosystem."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BeatmapSubmissionStrings.cs b/osu.Game/Localisation/BeatmapSubmissionStrings.cs new file mode 100644 index 0000000000..0cf0498daa --- /dev/null +++ b/osu.Game/Localisation/BeatmapSubmissionStrings.cs @@ -0,0 +1,164 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class BeatmapSubmissionStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapSubmission"; + + /// + /// "Beatmap submission" + /// + public static LocalisableString BeatmapSubmissionTitle => new TranslatableString(getKey(@"beatmap_submission_title"), @"Beatmap submission"); + + /// + /// "Share your beatmap with the world!" + /// + public static LocalisableString BeatmapSubmissionDescription => new TranslatableString(getKey(@"beatmap_submission_description"), @"Share your beatmap with the world!"); + + /// + /// "Content permissions" + /// + public static LocalisableString ContentPermissions => new TranslatableString(getKey(@"content_permissions"), @"Content permissions"); + + /// + /// "I understand" + /// + public static LocalisableString ContentPermissionsAcknowledgement => new TranslatableString(getKey(@"content_permissions_acknowledgement"), @"I understand"); + + /// + /// "Frequently asked questions" + /// + public static LocalisableString FrequentlyAskedQuestions => new TranslatableString(getKey(@"frequently_asked_questions"), @"Frequently asked questions"); + + /// + /// "Submission settings" + /// + public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings"); + + /// + /// "Submit beatmap!" + /// + public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!"); + + /// + /// "Exporting beatmap for compatibility..." + /// + public static LocalisableString Exporting => new TranslatableString(getKey(@"exporting"), @"Exporting beatmap for compatibility..."); + + /// + /// "Preparing for upload..." + /// + public static LocalisableString Preparing => new TranslatableString(getKey(@"preparing"), @"Preparing for upload..."); + + /// + /// "Uploading beatmap contents..." + /// + public static LocalisableString Uploading => new TranslatableString(getKey(@"uploading"), @"Uploading beatmap contents..."); + + /// + /// "Finishing up..." + /// + public static LocalisableString Finishing => new TranslatableString(getKey(@"finishing"), @"Finishing up..."); + + /// + /// "Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!" + /// + public static LocalisableString ContentPermissionsDisclaimer => new TranslatableString(getKey(@"content_permissions_disclaimer"), @"Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!"); + + /// + /// "Check the content usage guidelines for more information" + /// + public static LocalisableString CheckContentUsageGuidelines => new TranslatableString(getKey(@"check_content_usage_guidelines"), @"Check the content usage guidelines for more information"); + + /// + /// "Beatmap ranking criteria" + /// + public static LocalisableString BeatmapRankingCriteria => new TranslatableString(getKey(@"beatmap_ranking_criteria"), @"Beatmap ranking criteria"); + + /// + /// "Not sure you meet the guidelines? Check the list and speed up the ranking process!" + /// + public static LocalisableString BeatmapRankingCriteriaDescription => new TranslatableString(getKey(@"beatmap_ranking_criteria_description"), @"Not sure you meet the guidelines? Check the list and speed up the ranking process!"); + + /// + /// "Submission process" + /// + public static LocalisableString SubmissionProcess => new TranslatableString(getKey(@"submission_process"), @"Submission process"); + + /// + /// "Unsure about the submission process? Check out the wiki entry!" + /// + public static LocalisableString SubmissionProcessDescription => new TranslatableString(getKey(@"submission_process_description"), @"Unsure about the submission process? Check out the wiki entry!"); + + /// + /// "Mapping help forum" + /// + public static LocalisableString MappingHelpForum => new TranslatableString(getKey(@"mapping_help_forum"), @"Mapping help forum"); + + /// + /// "Got some questions about mapping and submission? Ask them in the forums!" + /// + public static LocalisableString MappingHelpForumDescription => new TranslatableString(getKey(@"mapping_help_forum_description"), @"Got some questions about mapping and submission? Ask them in the forums!"); + + /// + /// "Modding queues forum" + /// + public static LocalisableString ModdingQueuesForum => new TranslatableString(getKey(@"modding_queues_forum"), @"Modding queues forum"); + + /// + /// "Having trouble getting feedback? Why not ask in a mod queue!" + /// + public static LocalisableString ModdingQueuesForumDescription => new TranslatableString(getKey(@"modding_queues_forum_description"), @"Having trouble getting feedback? Why not ask in a mod queue!"); + + /// + /// "Where would you like to post your beatmap?" + /// + public static LocalisableString BeatmapSubmissionTargetCaption => new TranslatableString(getKey(@"beatmap_submission_target_caption"), @"Where would you like to post your beatmap?"); + + /// + /// "Works in Progress / Help (incomplete, not ready for ranking)" + /// + public static LocalisableString BeatmapSubmissionTargetWIP => new TranslatableString(getKey(@"beatmap_submission_target_wip"), @"Works in Progress / Help (incomplete, not ready for ranking)"); + + /// + /// "Pending (complete, ready for ranking)" + /// + public static LocalisableString BeatmapSubmissionTargetPending => new TranslatableString(getKey(@"beatmap_submission_target_pending"), @"Pending (complete, ready for ranking)"); + + /// + /// "Receive notifications for discussion replies" + /// + public static LocalisableString NotifyOnDiscussionReplies => new TranslatableString(getKey(@"notify_for_discussion_replies"), @"Receive notifications for discussion replies"); + + /// + /// "Load in browser after submission" + /// + public static LocalisableString LoadInBrowserAfterSubmission => new TranslatableString(getKey(@"load_in_browser_after_submission"), @"Load in browser after submission"); + + /// + /// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that this process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost." + /// + public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that this process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."); + + /// + /// "Empty beatmaps cannot be submitted." + /// + public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted."); + + /// + /// "Update beatmap!" + /// + public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!"); + + /// + /// "Upload NEW beatmap!" + /// + public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index b0a205eebe..a9bc3068da 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -59,6 +59,25 @@ namespace osu.Game.Localisation /// public static LocalisableString DailyChallenge => new TranslatableString(getKey(@"daily_challenge"), @"daily challenge"); + /// + /// "A few important words from your dev team!" + /// + public static LocalisableString MobileDisclaimerHeader => new TranslatableString(getKey(@"mobile_disclaimer_header"), @"A few important words from your dev team!"); + + /// + /// "While we have released osu! on mobile platforms to maximise the number of people that can enjoy the game, our focus is still on the PC version. + /// + /// Your experience will not be perfect, and may even feel subpar compared to games which are made mobile-first. + /// + /// Please bear with us as we continue to improve the game for you!" + /// + public static LocalisableString MobileDisclaimerBody => new TranslatableString(getKey(@"mobile_disclaimer_body"), + @"While we have released osu! on mobile platforms to maximise the number of people that can enjoy the game, our focus is still on the PC version. + +Your experience will not be perfect, and may even feel subpar compared to games which are made mobile-first. + +Please bear with us as we continue to improve the game for you!"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 243a100029..f9d0feb5e2 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -39,6 +39,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Default => new TranslatableString(getKey(@"default"), @"Default"); + /// + /// "Rename" + /// + public static LocalisableString Rename => new TranslatableString(getKey(@"rename"), @"Rename"); + /// /// "Export" /// diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index 066c07858c..bdb0348981 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -9,21 +9,6 @@ namespace osu.Game.Localisation { private const string prefix = @"osu.Game.Resources.Localisation.DebugSettings"; - /// - /// "Debug" - /// - public static LocalisableString DebugSectionHeader => new TranslatableString(getKey(@"debug_section_header"), @"Debug"); - - /// - /// "Show log overlay" - /// - public static LocalisableString ShowLogOverlay => new TranslatableString(getKey(@"show_log_overlay"), @"Show log overlay"); - - /// - /// "Bypass front-to-back render pass" - /// - public static LocalisableString BypassFrontToBackPass => new TranslatableString(getKey(@"bypass_front_to_back_pass"), @"Bypass front-to-back render pass"); - /// /// "Import files" /// @@ -34,16 +19,6 @@ namespace osu.Game.Localisation /// public static LocalisableString RunLatencyCertifier => new TranslatableString(getKey(@"run_latency_certifier"), @"Run latency certifier"); - /// - /// "Memory" - /// - public static LocalisableString MemoryHeader => new TranslatableString(getKey(@"memory_header"), @"Memory"); - - /// - /// "Clear all caches" - /// - public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorDialogsStrings.cs b/osu.Game/Localisation/EditorDialogsStrings.cs index 94f28c617c..3617dca81f 100644 --- a/osu.Game/Localisation/EditorDialogsStrings.cs +++ b/osu.Game/Localisation/EditorDialogsStrings.cs @@ -54,6 +54,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorReloadDialogHeader => new TranslatableString(getKey(@"editor_reload_dialog_header"), @"The editor must be reloaded to apply this change. The beatmap will be saved."); + /// + /// "Discard all unsaved changes? This cannot be undone." + /// + public static LocalisableString DiscardUnsavedChangesDialogHeader => new TranslatableString(getKey(@"discard_unsaved_changes_dialog_header"), @"Discard all unsaved changes? This cannot be undone."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs index 350517734f..8597b7d9a1 100644 --- a/osu.Game/Localisation/EditorSetupStrings.cs +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -198,6 +198,21 @@ namespace osu.Game.Localisation /// public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image"); + /// + /// "Apply this change to all difficulties?" + /// + public static LocalisableString ApplicationScopeSelectionTitle => new TranslatableString(getKey(@"application_scope_selection_title"), @"Apply this change to all difficulties?"); + + /// + /// "Apply to all difficulties" + /// + public static LocalisableString ApplyToAllDifficulties => new TranslatableString(getKey(@"apply_to_all_difficulties"), @"Apply to all difficulties"); + + /// + /// "Only apply to this difficulty" + /// + public static LocalisableString ApplyToThisDifficulty => new TranslatableString(getKey(@"apply_to_this_difficulty"), @"Only apply to this difficulty"); + /// /// "Ruleset ({0})" /// diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index 127bdd8355..4ce3e7f12b 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -69,6 +69,16 @@ namespace osu.Game.Localisation ///
public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty"); + /// + /// "Edit externally" + /// + public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally"); + + /// + /// "Submit beatmap" + /// + public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap"); + /// /// "setup" /// @@ -154,6 +164,46 @@ namespace osu.Game.Localisation ///
public static LocalisableString TimelineShowTicks => new TranslatableString(getKey(@"timeline_show_ticks"), @"Show ticks"); + /// + /// "Bookmarks" + /// + public static LocalisableString Bookmarks => new TranslatableString(getKey(@"bookmarks"), @"Bookmarks"); + + /// + /// "Add bookmark" + /// + public static LocalisableString AddBookmark => new TranslatableString(getKey(@"add_bookmark"), @"Add bookmark"); + + /// + /// "Remove closest bookmark" + /// + public static LocalisableString RemoveClosestBookmark => new TranslatableString(getKey(@"remove_closest_bookmark"), @"Remove closest bookmark"); + + /// + /// "Seek to previous bookmark" + /// + public static LocalisableString SeekToPreviousBookmark => new TranslatableString(getKey(@"seek_to_previous_bookmark"), @"Seek to previous bookmark"); + + /// + /// "Seek to next bookmark" + /// + public static LocalisableString SeekToNextBookmark => new TranslatableString(getKey(@"seek_to_next_bookmark"), @"Seek to next bookmark"); + + /// + /// "Reset bookmarks" + /// + public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks"); + + /// + /// "Open beatmap info page" + /// + public static LocalisableString OpenInfoPage => new TranslatableString(getKey(@"open_info_page"), @"Open beatmap info page"); + + /// + /// "Open beatmap discussion page" + /// + public static LocalisableString OpenDiscussionPage => new TranslatableString(getKey(@"open_discussion_page"), @"Open beatmap discussion page"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs index 521a77fe20..6293a4f840 100644 --- a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -15,10 +15,9 @@ namespace osu.Game.Localisation public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import"); /// - /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." + /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." /// - public static LocalisableString Description => new TranslatableString(getKey(@"description"), - @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); + public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); /// /// "previous osu! install" @@ -38,8 +37,7 @@ namespace osu.Game.Localisation /// /// "Your import will continue in the background. Check on its progress in the notifications sidebar!" /// - public static LocalisableString ImportInProgress => - new TranslatableString(getKey(@"import_in_progress"), @"Your import will continue in the background. Check on its progress in the notifications sidebar!"); + public static LocalisableString ImportInProgress => new TranslatableString(getKey(@"import_in_progress"), @"Your import will continue in the background. Check on its progress in the notifications sidebar!"); /// /// "calculating..." diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 42623f4632..83a3af574c 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -44,6 +44,11 @@ namespace osu.Game.Localisation /// public static LocalisableString CheckUpdate => new TranslatableString(getKey(@"check_update"), @"Check for updates"); + /// + /// "Checking for updates" + /// + public static LocalisableString CheckingForUpdates => new TranslatableString(getKey(@"checking_for_updates"), @"Checking for updates"); + /// /// "Open osu! folder" /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index ed80704a0a..34b9e1fecc 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -429,6 +429,41 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorSeekToNextSamplePoint => new TranslatableString(getKey(@"editor_seek_to_next_sample_point"), @"Seek to next sample point"); + /// + /// "Add bookmark" + /// + public static LocalisableString EditorAddBookmark => new TranslatableString(getKey(@"editor_add_bookmark"), @"Add bookmark"); + + /// + /// "Remove closest bookmark" + /// + public static LocalisableString EditorRemoveClosestBookmark => new TranslatableString(getKey(@"editor_remove_closest_bookmark"), @"Remove closest bookmark"); + + /// + /// "Seek to previous bookmark" + /// + public static LocalisableString EditorSeekToPreviousBookmark => new TranslatableString(getKey(@"editor_seek_to_previous_bookmark"), @"Seek to previous bookmark"); + + /// + /// "Seek to next bookmark" + /// + public static LocalisableString EditorSeekToNextBookmark => new TranslatableString(getKey(@"editor_seek_to_next_bookmark"), @"Seek to next bookmark"); + + /// + /// "Absolute scroll song list" + /// + public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list"); + + /// + /// "Toggle movement control" + /// + public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control"); + + /// + /// "Discard unsaved changes" + /// + public static LocalisableString EditorDiscardUnsavedChanges => new TranslatableString(getKey(@"editor_discard_unsaved_changes"), @"Discard unsaved changes"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/HUD/SpectatorListStrings.cs b/osu.Game/Localisation/HUD/SpectatorListStrings.cs new file mode 100644 index 0000000000..8d82250526 --- /dev/null +++ b/osu.Game/Localisation/HUD/SpectatorListStrings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.HUD +{ + public static class SpectatorListStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SpectatorList"; + + /// + /// "Spectators ({0})" + /// + public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/LeaderboardStrings.cs b/osu.Game/Localisation/LeaderboardStrings.cs index 8e53f8e88c..816406bf31 100644 --- a/osu.Game/Localisation/LeaderboardStrings.cs +++ b/osu.Game/Localisation/LeaderboardStrings.cs @@ -44,6 +44,11 @@ namespace osu.Game.Localisation /// public static LocalisableString PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard => new TranslatableString(getKey(@"please_invest_in_an_osu_supporter_tag_to_view_this_leaderboard"), @"Please invest in an osu!supporter tag to view this leaderboard!"); + /// + /// "You are not on a team. Maybe you should join one!" + /// + public static LocalisableString NoTeam => new TranslatableString(getKey(@"no_team"), @"You are not on a team. Maybe you should join one!"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MenuTipStrings.cs b/osu.Game/Localisation/MenuTipStrings.cs index f97ad5fa2c..9d398e8e64 100644 --- a/osu.Game/Localisation/MenuTipStrings.cs +++ b/osu.Game/Localisation/MenuTipStrings.cs @@ -119,6 +119,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AutoplayBeatmapShortcut => new TranslatableString(getKey(@"autoplay_beatmap_shortcut"), @"Ctrl-Enter at song select will start a beatmap in autoplay mode!"); + /// + /// ""Lazer" is not an English word. The correct spelling for the bright light is "laser"." + /// + public static LocalisableString LazerIsNotAWord => new TranslatableString(getKey(@"lazer_is_not_a_word"), @"""Lazer"" is not an English word. The correct spelling for the bright light is ""laser""."); + /// /// "Multithreading support means that even with low "FPS" your input and judgements will be accurate!" /// diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index e61af07364..c92c3b6ddc 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -25,9 +25,9 @@ namespace osu.Game.Localisation public static LocalisableString HighPrecisionMouse => new TranslatableString(getKey(@"high_precision_mouse"), @"High precision mouse"); /// - /// "Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as "Raw Input"." + /// "Attempts to bypass any operating system mouse acceleration. On Windows, this is equivalent to what used to be known as "Raw Input"." /// - public static LocalisableString HighPrecisionMouseTooltip => new TranslatableString(getKey(@"high_precision_mouse_tooltip"), @"Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as ""Raw Input""."); + public static LocalisableString HighPrecisionMouseTooltip => new TranslatableString(getKey(@"high_precision_mouse_tooltip"), @"Attempts to bypass any operating system mouse acceleration. On Windows, this is equivalent to what used to be known as ""Raw Input""."); /// /// "Confine mouse cursor to window" diff --git a/osu.Game/Localisation/MultiplayerMatchStrings.cs b/osu.Game/Localisation/MultiplayerMatchStrings.cs index 95c7168a09..8c9e76d722 100644 --- a/osu.Game/Localisation/MultiplayerMatchStrings.cs +++ b/osu.Game/Localisation/MultiplayerMatchStrings.cs @@ -24,6 +24,21 @@ namespace osu.Game.Localisation /// public static LocalisableString StartMatchWithCountdown(string humanReadableTime) => new TranslatableString(getKey(@"start_match_width_countdown"), @"Start match in {0}", humanReadableTime); + /// + /// "Choose the mods which all players should play with." + /// + public static LocalisableString RequiredModsButtonTooltip => new TranslatableString(getKey(@"required_mods_button_tooltip"), @"Choose the mods which all players should play with."); + + /// + /// "Each player can choose their preferred mods from a selected list." + /// + public static LocalisableString FreeModsButtonTooltip => new TranslatableString(getKey(@"free_mods_button_tooltip"), @"Each player can choose their preferred mods from a selected list."); + + /// + /// "Each player can choose their preferred difficulty, ruleset and mods." + /// + public static LocalisableString FreestyleButtonTooltip => new TranslatableString(getKey(@"freestyle_button_tooltip"), @"Each player can choose their preferred difficulty, ruleset and mods."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index d8f768f2d8..bb2990f782 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -84,12 +84,12 @@ Please try changing your audio device to a working setting."); public static LocalisableString LinkTypeNotSupported => new TranslatableString(getKey(@"unsupported_link_type"), @"This link type is not yet supported!"); /// - /// "You received a private message from '{0}'. Click to read it!" + /// "You received a private message from '{0}'. Click to read it!" /// public static LocalisableString PrivateMessageReceived(string username) => new TranslatableString(getKey(@"private_message_received"), @"You received a private message from '{0}'. Click to read it!", username); /// - /// "Your name was mentioned in chat by '{0}'. Click to find out why!" + /// "Your name was mentioned in chat by '{0}'. Click to find out why!" /// public static LocalisableString YourNameWasMentioned(string username) => new TranslatableString(getKey(@"your_name_was_mentioned"), @"Your name was mentioned in chat by '{0}'. Click to find out why!", username); @@ -115,7 +115,7 @@ Please try changing your audio device to a working setting."); /// /// "You are now running osu! {0}. - /// Click to see what's new!" + /// Click to see what's new!" /// public static LocalisableString GameVersionAfterUpdate(string version) => new TranslatableString(getKey(@"game_version_after_update"), @"You are now running osu! {0}. Click to see what's new!", version); diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 8e8c81cf59..98364a3f5a 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -29,6 +29,16 @@ namespace osu.Game.Localisation /// public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message"); + /// + /// "Show notification popups when friends change status" + /// + public static LocalisableString NotifyOnFriendPresenceChange => new TranslatableString(getKey(@"notify_on_friend_presence_change"), @"Show notification popups when friends change status"); + + /// + /// "Notifications will be shown when friends go online/offline." + /// + public static LocalisableString NotifyOnFriendPresenceChangeTooltip => new TranslatableString(getKey(@"notify_on_friend_presence_change_tooltip"), @"Notifications will be shown when friends go online/offline."); + /// /// "Integrations" /// @@ -84,6 +94,6 @@ namespace osu.Game.Localisation /// public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/RoomStatusPillStrings.cs b/osu.Game/Localisation/RoomStatusPillStrings.cs new file mode 100644 index 0000000000..5b4aa776ab --- /dev/null +++ b/osu.Game/Localisation/RoomStatusPillStrings.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class RoomStatusPillStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RoomStatusPill"; + + /// + /// "Ended" + /// + public static LocalisableString Ended => new TranslatableString(getKey(@"ended"), @"Ended"); + + /// + /// "Playing" + /// + public static LocalisableString Playing => new TranslatableString(getKey(@"playing"), @"Playing"); + + /// + /// "Open (Private)" + /// + public static LocalisableString OpenPrivate => new TranslatableString(getKey(@"open_private"), @"Open (Private)"); + + /// + /// "Open" + /// + public static LocalisableString Open => new TranslatableString(getKey(@"open"), @"Open"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index 9434cd53de..fc4fb58e26 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -89,6 +89,26 @@ namespace osu.Game.Localisation /// public static LocalisableString TouchControlScheme => new TranslatableString(getKey(@"touch_control_scheme"), @"Touch control scheme"); + /// + /// "Mobile layout" + /// + public static LocalisableString MobileLayout => new TranslatableString(getKey(@"mobile_layout"), @"Mobile layout"); + + /// + /// "Portrait (expanded columns)" + /// + public static LocalisableString PortraitExpandedColumns => new TranslatableString(getKey(@"portrait_expanded_columns"), @"Portrait (expanded columns)"); + + /// + /// "Landscape (expanded columns)" + /// + public static LocalisableString LandscapeExpandedColumns => new TranslatableString(getKey(@"landscape_expanded_columns"), @"Landscape (expanded columns)"); + + /// + /// "Landscape (touch overlay)" + /// + public static LocalisableString LandscapeTouchOverlay => new TranslatableString(getKey(@"landscape_touch_overlay"), @"Landscape (touch overlay)"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs new file mode 100644 index 0000000000..22f9fe6d02 --- /dev/null +++ b/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.SkinComponents +{ + public static class SkinnableModDisplayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinnableModDisplay"; + + /// + /// "Show extended information" + /// + public static LocalisableString ShowExtendedInformation => new TranslatableString(getKey(@"show_extended_information"), @"Show extended information"); + + /// + /// "Whether to show extended information for each mod." + /// + public static LocalisableString ShowExtendedInformationDescription => + new TranslatableString(getKey(@"whether_to_show_extended_information"), @"Whether to show extended information for each mod."); + + /// + /// "Display direction" + /// + public static LocalisableString DisplayDirection => new TranslatableString(getKey(@"display_direction"), "Display direction"); + + /// + /// "Expansion mode" + /// + public static LocalisableString ExpansionMode => new TranslatableString(getKey(@"expansion_mode"), @"Expansion mode"); + + /// + /// "How the mod display expands when interacted with." + /// + public static LocalisableString ExpansionModeDescription => new TranslatableString(getKey(@"how_the_mod_display_expands"), @"How the mod display expands when interacted with."); + + /// + /// "Expand on hover" + /// + public static LocalisableString ExpandOnHover => new TranslatableString(getKey(@"expand_on_hover"), @"Expand on hover"); + + /// + /// "Always contracted" + /// + public static LocalisableString AlwaysContracted => new TranslatableString(getKey(@"always_contracted"), @"Always contracted"); + + /// + /// "Always expanded" + /// + public static LocalisableString AlwaysExpanded => new TranslatableString(getKey(@"always_expanded"), @"Always expanded"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 4b6b0ce1d6..16dca7fd87 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -54,16 +54,6 @@ namespace osu.Game.Localisation /// public static LocalisableString BeatmapHitsounds => new TranslatableString(getKey(@"beatmap_hitsounds"), @"Beatmap hitsounds"); - /// - /// "Export selected skin" - /// - public static LocalisableString ExportSkinButton => new TranslatableString(getKey(@"export_skin_button"), @"Export selected skin"); - - /// - /// "Delete selected skin" - /// - public static LocalisableString DeleteSkinButton => new TranslatableString(getKey(@"delete_skin_button"), @"Delete selected skin"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs index 6c2e3c1f9c..ff0ced457f 100644 --- a/osu.Game/Localisation/TabletSettingsStrings.cs +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -59,6 +59,11 @@ namespace osu.Game.Localisation /// public static LocalisableString LockAspectRatio => new TranslatableString(getKey(@"lock_aspect_ratio"), @"Lock aspect ratio"); + /// + /// "Tip pressure for click" + /// + public static LocalisableString TipPressureForClick => new TranslatableString(getKey(@"tip_pressure_for_click"), "Tip pressure for click"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs index 49e8d00371..b520511d8f 100644 --- a/osu.Game/Localisation/ToastStrings.cs +++ b/osu.Game/Localisation/ToastStrings.cs @@ -45,9 +45,9 @@ namespace osu.Game.Localisation public static LocalisableString SkinSaved => new TranslatableString(getKey(@"skin_saved"), @"Skin saved"); /// - /// "Link copied to clipboard" + /// "Copied to clipboard" /// - public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"Link copied to clipboard"); + public static LocalisableString CopiedToClipboard => new TranslatableString(getKey(@"copied_to_clipboard"), @"Copied to clipboard"); /// /// "Speed changed to {0:N2}x" diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index ec48fa2436..36712fbdaa 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -13,6 +14,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -38,9 +40,7 @@ namespace osu.Game.Online.API private readonly Queue queue = new Queue(); - public string APIEndpointUrl { get; } - - public string WebsiteRootUrl { get; } + public EndpointConfiguration Endpoints { get; } /// /// The API response version. @@ -58,7 +58,6 @@ namespace osu.Game.Online.API public IBindable LocalUser => localUser; public IBindableList Friends => friends; - public IBindable Activity => activity; public INotificationsClient NotificationsClient { get; } @@ -68,18 +67,13 @@ namespace osu.Game.Online.API private BindableList friends { get; } = new BindableList(); - private Bindable activity { get; } = new Bindable(); - - private Bindable configStatus { get; } = new Bindable(); - private Bindable localUserStatus { get; } = new Bindable(); - protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); + private readonly Bindable configStatus = new Bindable(); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); - private readonly Logger log; - public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) + public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpoints, string versionHash) { this.game = game; this.config = config; @@ -93,14 +87,13 @@ namespace osu.Game.Online.API APIVersion = now.Year * 10000 + now.Month * 100 + now.Day; } - APIEndpointUrl = endpointConfiguration.APIEndpointUrl; - WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; + Endpoints = endpoints; NotificationsClient = setUpNotificationsClient(); - authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); + authentication = new OAuth(endpoints.APIClientID, endpoints.APIClientSecret, Endpoints.APIUrl); log = Logger.GetLogger(LoggingTarget.Network); - log.Add($@"API endpoint root: {APIEndpointUrl}"); + log.Add($@"API endpoint root: {Endpoints.APIUrl}"); log.Add($@"API request version: {APIVersion}"); ProvidedUsername = config.Get(OsuSetting.Username); @@ -110,16 +103,14 @@ namespace osu.Game.Online.API config.BindWith(OsuSetting.UserOnlineStatus, configStatus); - localUser.BindValueChanged(u => + if (HasLogin) { - u.OldValue?.Activity.UnbindFrom(activity); - u.NewValue.Activity.BindTo(activity); + // Early call to ensure the local user / "logged in" state is correct immediately. + setPlaceholderLocalUser(); - u.OldValue?.Status.UnbindFrom(localUserStatus); - u.NewValue.Status.BindTo(localUserStatus); - }, true); - - localUserStatus.BindTo(configStatus); + // This is required so that Queue() requests during startup sequence don't fail due to "not logged in". + state.Value = APIState.Connecting; + } var thread = new Thread(run) { @@ -193,13 +184,16 @@ namespace osu.Game.Online.API Debug.Assert(HasLogin); - // Ensure that we are in an online state. If not, attempt a connect. + // Ensure that we are in an online state. If not, attempt to connect. if (state.Value != APIState.Online) { attemptConnect(); if (state.Value != APIState.Online) + { + Thread.Sleep(50); continue; + } } // hard bail if we can't get a valid access token. @@ -248,21 +242,12 @@ namespace osu.Game.Online.API private void attemptConnect() { if (localUser.IsDefault) - { - // Show a placeholder user if saved credentials are available. - // This is useful for storing local scores and showing a placeholder username after starting the game, - // until a valid connection has been established. - setLocalUser(new APIUser - { - Username = ProvidedUsername, - Status = { Value = configStatus.Value ?? UserStatus.Online } - }); - } + Scheduler.Add(setPlaceholderLocalUser, false); // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); - if (!authentication.HasValidAccessToken) + if (!authentication.HasValidAccessToken && HasLogin) { state.Value = APIState.Connecting; LastLoginError = null; @@ -271,6 +256,10 @@ namespace osu.Game.Online.API { authentication.AuthenticateWithLogin(ProvidedUsername, password); } + catch (WebRequestFlushedException) + { + return; + } catch (Exception e) { //todo: this fails even on network-related issues. we should probably handle those differently. @@ -331,7 +320,7 @@ namespace osu.Game.Online.API log.Add(@"Login no longer valid"); Logout(); } - else + else if (ex is not WebRequestFlushedException) { state.Value = APIState.Failing; } @@ -339,10 +328,9 @@ namespace osu.Game.Online.API userReq.Success += me => { - me.Status.Value = configStatus.Value ?? UserStatus.Online; - - setLocalUser(me); + Debug.Assert(ThreadSafety.IsUpdateThread); + localUser.Value = me; state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; }; @@ -366,6 +354,22 @@ namespace osu.Game.Online.API Thread.Sleep(500); } + /// + /// Show a placeholder user if saved credentials are available. + /// This is useful for storing local scores and showing a placeholder username after starting the game, + /// until a valid connection has been established. + /// + private void setPlaceholderLocalUser() + { + if (!localUser.IsDefault) + return; + + localUser.Value = new APIUser + { + Username = ProvidedUsername + }; + } + public void Perform(APIRequest request) { try @@ -409,7 +413,7 @@ namespace osu.Game.Online.API var req = new RegistrationRequest { - Url = $@"{APIEndpointUrl}/users", + Url = $@"{Endpoints.APIUrl}/users", Method = HttpMethod.Post, Username = username, Email = email, @@ -497,6 +501,11 @@ namespace osu.Game.Online.API handleWebException(we); return false; } + catch (WebRequestFlushedException wrf) + { + log.Add(wrf.Message); + return false; + } catch (Exception ex) { Logger.Error(ex, "Error occurred while handling an API request."); @@ -578,7 +587,7 @@ namespace osu.Game.Online.API if (failOldRequests) { foreach (var req in oldQueueRequests) - req.Fail(new WebException($@"Request failed from flush operation (state {state.Value})")); + req.Fail(new WebRequestFlushedException(state.Value)); } } } @@ -588,12 +597,14 @@ namespace osu.Game.Online.API password = null; SecondFactorCode = null; authentication.Clear(); + + // Reset the status to be broadcast on the next login, in case multiple players share the same system. configStatus.Value = UserStatus.Online; // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => { - setLocalUser(createGuestUser()); + localUser.Value = createGuestUser(); friends.Clear(); }); @@ -607,11 +618,21 @@ namespace osu.Game.Online.API return; var friendsReq = new GetFriendsRequest(); - friendsReq.Failure += _ => state.Value = APIState.Failing; + friendsReq.Failure += ex => + { + if (ex is not WebRequestFlushedException) + state.Value = APIState.Failing; + }; friendsReq.Success += res => { - friends.Clear(); - friends.AddRange(res); + var existingFriends = friends.Select(f => f.TargetID).ToHashSet(); + var updatedFriends = res.Select(f => f.TargetID).ToHashSet(); + + // Add new friends into local list. + friends.AddRange(res.Where(r => !existingFriends.Contains(r.TargetID))); + + // Remove non-friends from local list. + friends.RemoveAll(f => !updatedFriends.Contains(f.TargetID)); }; Queue(friendsReq); @@ -619,8 +640,6 @@ namespace osu.Game.Online.API private static APIUser createGuestUser() => new GuestUser(); - private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -628,6 +647,14 @@ namespace osu.Game.Online.API flushQueue(); cancellationToken.Cancel(); } + + private class WebRequestFlushedException : Exception + { + public WebRequestFlushedException(APIState state) + : base($@"Request failed from flush operation (state {state})") + { + } + } } internal class GuestUser : APIUser diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 5cbe9040ba..9d9873cc6f 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -71,7 +71,7 @@ namespace osu.Game.Online.API protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri); - protected virtual string Uri => $@"{API!.APIEndpointUrl}/api/v2/{Target}"; + protected virtual string Uri => $@"{API!.Endpoints.APIUrl}/api/v2/{Target}"; protected IAPIProvider? API; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 5d63c04925..f9649cdd88 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -12,7 +12,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Tests; -using osu.Game.Users; namespace osu.Game.Online.API { @@ -28,8 +27,6 @@ namespace osu.Game.Online.API public BindableList Friends { get; } = new BindableList(); - public Bindable Activity { get; } = new Bindable(); - public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; @@ -44,9 +41,11 @@ namespace osu.Game.Online.API public string ProvidedUsername => LocalUser.Value.Username; - public string APIEndpointUrl => "http://localhost"; - - public string WebsiteRootUrl => "http://localhost"; + public EndpointConfiguration Endpoints { get; } = new EndpointConfiguration + { + APIUrl = "http://localhost", + WebsiteUrl = "http://localhost", + }; public int APIVersion => int.Parse(DateTime.Now.ToString("yyyyMMdd")); @@ -69,15 +68,6 @@ namespace osu.Game.Online.API /// public IBindable State => state; - public DummyAPIAccess() - { - LocalUser.BindValueChanged(u => - { - u.OldValue?.Activity.UnbindFrom(Activity); - u.NewValue.Activity.BindTo(Activity); - }, true); - } - public virtual void Queue(APIRequest request) { request.AttachAPI(this); @@ -204,7 +194,6 @@ namespace osu.Game.Online.API IBindable IAPIProvider.LocalUser => LocalUser; IBindableList IAPIProvider.Friends => Friends; - IBindable IAPIProvider.Activity => Activity; /// /// Skip 2FA requirement for next login. diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1c4b2da742..54eaaaafc2 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -8,7 +8,6 @@ using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; -using osu.Game.Users; namespace osu.Game.Online.API { @@ -24,11 +23,6 @@ namespace osu.Game.Online.API /// IBindableList Friends { get; } - /// - /// The current user's activity. - /// - IBindable Activity { get; } - /// /// The language supplied by this provider to API requests. /// @@ -57,14 +51,9 @@ namespace osu.Game.Online.API string ProvidedUsername { get; } /// - /// The URL endpoint for this API. Does not include a trailing slash. + /// Holds configuration for online endpoints. /// - string APIEndpointUrl { get; } - - /// - /// The root URL of the website, excluding the trailing slash. - /// - string WebsiteRootUrl { get; } + EndpointConfiguration Endpoints { get; } /// /// The version of the API. diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index 3fad032531..8da83d2aad 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -10,10 +10,12 @@ using osu.Game.Configuration; namespace osu.Game.Online.API { - public class ModSettingsDictionaryFormatter : IMessagePackFormatter> + public class ModSettingsDictionaryFormatter : IMessagePackFormatter?> { - public void Serialize(ref MessagePackWriter writer, Dictionary value, MessagePackSerializerOptions options) + public void Serialize(ref MessagePackWriter writer, Dictionary? value, MessagePackSerializerOptions options) { + if (value == null) return; + var primitiveFormatter = PrimitiveObjectFormatter.Instance; writer.WriteArrayHeader(value.Count); diff --git a/osu.Game/Online/API/Requests/APIUploadRequest.cs b/osu.Game/Online/API/Requests/APIUploadRequest.cs new file mode 100644 index 0000000000..3503b4cebb --- /dev/null +++ b/osu.Game/Online/API/Requests/APIUploadRequest.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public abstract class APIUploadRequest : APIRequest + { + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.UploadProgress += onUploadProgress; + return request; + } + + private void onUploadProgress(long current, long total) + { + Debug.Assert(API != null); + API.Schedule(() => Progressed?.Invoke(current, total)); + } + + public event APIProgressHandler? Progressed; + } +} diff --git a/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs new file mode 100644 index 0000000000..911c4fa5f1 --- /dev/null +++ b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . 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 AddBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public AddBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags/{TagID}"; + } +} diff --git a/osu.Game/Online/API/Requests/ClosePlaylistRequest.cs b/osu.Game/Online/API/Requests/ClosePlaylistRequest.cs new file mode 100644 index 0000000000..545266491e --- /dev/null +++ b/osu.Game/Online/API/Requests/ClosePlaylistRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . 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 ClosePlaylistRequest : APIRequest + { + private readonly long roomId; + + public ClosePlaylistRequest(long roomId) + { + this.roomId = roomId; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.Method = HttpMethod.Delete; + return request; + } + + protected override string Target => $@"rooms/{roomId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index cd75ff4e31..fe7ba8c33d 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -13,14 +13,14 @@ namespace osu.Game.Online.API.Requests /// public class GetUsersRequest : APIRequest { - public readonly int[] UserIds; + public const int MAX_IDS_PER_REQUEST = 50; - private const int max_ids_per_request = 50; + public readonly int[] UserIds; public GetUsersRequest(int[] userIds) { - if (userIds.Length > max_ids_per_request) - throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); + if (userIds.Length > MAX_IDS_PER_REQUEST) + throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {MAX_IDS_PER_REQUEST} IDs at once"); UserIds = userIds; } diff --git a/osu.Game/Online/API/Requests/ListTagsRequest.cs b/osu.Game/Online/API/Requests/ListTagsRequest.cs new file mode 100644 index 0000000000..ac4b1a3e2a --- /dev/null +++ b/osu.Game/Online/API/Requests/ListTagsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class ListTagsRequest : APIRequest + { + protected override string Target => "tags"; + } +} diff --git a/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs new file mode 100644 index 0000000000..df3c9d071c --- /dev/null +++ b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class PatchBeatmapPackageRequest : APIUploadRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl!}/beatmapsets/{BeatmapSetID}"; + } + } + + protected override string Target => throw new NotSupportedException(); + + public uint BeatmapSetID { get; } + + public Dictionary FilesChanged { get; } = new Dictionary(); + + public HashSet FilesDeleted { get; } = new HashSet(); + + public PatchBeatmapPackageRequest(uint beatmapSetId) + { + BeatmapSetID = beatmapSetId; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.Method = HttpMethod.Patch; + + foreach ((string filename, byte[] content) in FilesChanged) + request.AddFile(@"filesChanged", content, filename); + + foreach (string filename in FilesDeleted) + request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form); + + request.Timeout = 600_000; + return request; + } + } +} diff --git a/osu.Game/Online/API/Requests/PutBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/PutBeatmapSetRequest.cs new file mode 100644 index 0000000000..ec233b5df8 --- /dev/null +++ b/osu.Game/Online/API/Requests/PutBeatmapSetRequest.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using osu.Framework.IO.Network; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Edit.Submission; + +namespace osu.Game.Online.API.Requests +{ + public class PutBeatmapSetRequest : APIRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets"; + } + } + + protected override string Target => throw new NotSupportedException(); + + [JsonProperty("beatmapset_id")] + public uint? BeatmapSetID { get; init; } + + [JsonProperty("beatmaps_to_create")] + public uint BeatmapsToCreate { get; init; } + + [JsonProperty("beatmaps_to_keep")] + public uint[] BeatmapsToKeep { get; init; } = []; + + [JsonProperty("target")] + public BeatmapSubmissionTarget SubmissionTarget { get; init; } + + [JsonProperty("notify_on_discussion_replies")] + public bool NotifyOnDiscussionReplies { get; init; } + + private PutBeatmapSetRequest() + { + } + + public static PutBeatmapSetRequest CreateNew(uint beatmapCount, BeatmapSubmissionSettings settings) => new PutBeatmapSetRequest + { + BeatmapsToCreate = beatmapCount, + SubmissionTarget = settings.Target.Value, + NotifyOnDiscussionReplies = settings.NotifyOnDiscussionReplies.Value, + }; + + public static PutBeatmapSetRequest UpdateExisting(uint beatmapSetId, IEnumerable beatmapsToKeep, uint beatmapsToCreate, BeatmapSubmissionSettings settings) => new PutBeatmapSetRequest + { + BeatmapSetID = beatmapSetId, + BeatmapsToKeep = beatmapsToKeep.ToArray(), + BeatmapsToCreate = beatmapsToCreate, + SubmissionTarget = settings.Target.Value, + NotifyOnDiscussionReplies = settings.NotifyOnDiscussionReplies.Value, + }; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + req.ContentType = @"application/json"; + req.AddRaw(JsonConvert.SerializeObject(this)); + return req; + } + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum BeatmapSubmissionTarget + { + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))] + WIP, + + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))] + Pending, + } +} diff --git a/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs new file mode 100644 index 0000000000..4ac00e28f4 --- /dev/null +++ b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . 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 RemoveBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public RemoveBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags/{TagID}"; + } +} diff --git a/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs new file mode 100644 index 0000000000..de8af6a623 --- /dev/null +++ b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class ReplaceBeatmapPackageRequest : APIUploadRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets/{BeatmapSetID}"; + } + } + + protected override string Target => throw new NotSupportedException(); + + public uint BeatmapSetID { get; } + + private readonly byte[] oszPackage; + + public ReplaceBeatmapPackageRequest(uint beatmapSetID, byte[] oszPackage) + { + this.oszPackage = oszPackage; + BeatmapSetID = beatmapSetID; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.AddFile(@"beatmapArchive", oszPackage); + request.Method = HttpMethod.Put; + request.Timeout = 600_000; + return request; + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index e5ecfe2c99..055d2dd8e2 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -95,6 +95,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"failtimes")] public APIFailTimes? FailTimes { get; set; } + [JsonProperty(@"top_tag_ids")] + public APIBeatmapTag[]? TopTags { get; set; } + + [JsonProperty(@"current_user_tag_ids")] + public long[]? OwnTagIds { get; set; } + [JsonProperty(@"max_combo")] public int? MaxCombo { get; set; } @@ -103,6 +109,9 @@ namespace osu.Game.Online.API.Requests.Responses public double BPM { get; set; } + [JsonProperty(@"owners")] + public BeatmapOwner[] BeatmapOwners { get; set; } = Array.Empty(); + #region Implementation of IBeatmapInfo public IBeatmapMetadataInfo Metadata => (BeatmapSet as IBeatmapSetInfo)?.Metadata ?? new BeatmapMetadata(); @@ -171,5 +180,14 @@ namespace osu.Game.Online.API.Requests.Responses // ReSharper disable once NonReadonlyMemberInGetHashCode public override int GetHashCode() => OnlineID; } + + public class BeatmapOwner + { + [JsonProperty(@"id")] + public int Id { get; set; } + + [JsonProperty(@"username")] + public string Username { get; set; } = string.Empty; + } } } diff --git a/osu.Game/Online/API/Requests/Responses/APITag.cs b/osu.Game/Online/API/Requests/Responses/APITag.cs new file mode 100644 index 0000000000..4dd18663af --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITag.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . 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 APITag + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("description")] + public string Description { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APITagCollection.cs b/osu.Game/Online/API/Requests/Responses/APITagCollection.cs new file mode 100644 index 0000000000..a177699348 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITagCollection.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APITagCollection + { + [JsonProperty("tags")] + public APITag[] Tags { get; set; } = Array.Empty(); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APITeam.cs b/osu.Game/Online/API/Requests/Responses/APITeam.cs new file mode 100644 index 0000000000..b4fcc2d26e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITeam.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . 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 +{ + [JsonObject(MemberSerialization.OptIn)] + public class APITeam + { + [JsonProperty(@"id")] + public int Id { get; set; } = 1; + + [JsonProperty(@"name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty(@"short_name")] + public string ShortName { get; set; } = string.Empty; + + [JsonProperty(@"flag_url")] + public string FlagUrl = string.Empty; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index a829484506..4e219cdf22 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -8,8 +8,8 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Bindables; using osu.Game.Extensions; +using osu.Game.Online.Metadata; using osu.Game.Users; namespace osu.Game.Online.API.Requests.Responses @@ -56,9 +56,9 @@ namespace osu.Game.Online.API.Requests.Responses set => countryCodeString = value.ToString(); } - public readonly Bindable Status = new Bindable(); - - public readonly Bindable Activity = new Bindable(); + [JsonProperty(@"team")] + [CanBeNull] + public APITeam Team { get; set; } [JsonProperty(@"profile_colour")] public string Colour; @@ -112,8 +112,13 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"is_active")] public bool Active; + /// + /// From osu-web's perspective, whether a user was recently online. + /// This doesn't imply the user is online in a lazer client (may be updated from stable or web browser). + /// Use for real-time lazer online status checks. + /// [JsonProperty(@"is_online")] - public bool IsOnline; + public bool WasRecentlyOnline; [JsonProperty(@"pm_friends_only")] public bool PMFriendsOnly; diff --git a/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.cs b/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.cs new file mode 100644 index 0000000000..e3ec617039 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class PutBeatmapSetResponse + { + [JsonProperty("beatmapset_id")] + public uint BeatmapSetId { get; set; } + + [JsonProperty("beatmap_ids")] + public ICollection BeatmapIds { get; set; } = Array.Empty(); + + [JsonProperty("files")] + public ICollection Files { get; set; } = Array.Empty(); + } + + public struct BeatmapSetFile + { + [JsonProperty("filename")] + public string Filename { get; set; } + + [JsonProperty("sha2_hash")] + public string SHA2Hash { get; set; } + } +} diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 74e85c595c..e9ca0a8ed2 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -411,7 +411,7 @@ namespace osu.Game.Online.Chat } /// - /// Find an existing channel instance for the provided channel. Lookup is performed basd on ID. + /// Find an existing channel instance for the provided channel. Lookup is performed based on ID. /// The provided channel may be used if an existing instance is not found. /// /// A candidate channel to be used for lookup or permanently on lookup failure. diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index bd628e90c4..4fb890c2cc 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -14,5 +14,6 @@ namespace osu.Game.Online.Chat Group, System, Announce, + Team, } } diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index fa107a0e43..e4baeb4838 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -56,7 +56,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours) { - IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue; } protected override IEnumerable EffectTargets => Parts; diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 75b161d57b..258cca2ad5 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -4,13 +4,16 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Notifications; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Online.Chat @@ -23,9 +26,15 @@ namespace osu.Game.Online.Chat [Resolved] private Clipboard clipboard { get; set; } = null!; - [Resolved(CanBeNull = true)] + [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + private Bindable externalLinkWarning = null!; [BackgroundDependencyLoader(true)] @@ -34,9 +43,51 @@ namespace osu.Game.Online.Chat externalLinkWarning = config.GetBindable(OsuSetting.ExternalLinkWarning); } - public void OpenUrlExternally(string url, bool bypassWarning = false) + public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.Default) { - if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null) + bool isTrustedDomain; + + if (url.StartsWith('/')) + { + url = $"{api.Endpoints.WebsiteUrl}{url}"; + isTrustedDomain = true; + } + else + { + isTrustedDomain = url.StartsWith(api.Endpoints.WebsiteUrl, StringComparison.Ordinal); + } + + if (!url.CheckIsValidUrl()) + { + notificationOverlay?.Post(new SimpleErrorNotification + { + Text = NotificationsStrings.UnsupportedOrDangerousUrlProtocol(url), + }); + + return; + } + + bool shouldWarn; + + switch (warnMode) + { + case LinkWarnMode.Default: + shouldWarn = externalLinkWarning.Value && !isTrustedDomain; + break; + + case LinkWarnMode.AlwaysWarn: + shouldWarn = true; + break; + + case LinkWarnMode.NeverWarn: + shouldWarn = false; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(warnMode), warnMode, null); + } + + if (dialogOverlay != null && shouldWarn) dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => clipboard.SetText(url))); else host.OpenUrlExternally(url); diff --git a/osu.Game/Online/Chat/LinkWarnMode.cs b/osu.Game/Online/Chat/LinkWarnMode.cs new file mode 100644 index 0000000000..0acd3994d8 --- /dev/null +++ b/osu.Game/Online/Chat/LinkWarnMode.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.Chat +{ + public enum LinkWarnMode + { + /// + /// Will show a dialog when opening a URL that is not on a trusted domain. + /// + Default, + + /// + /// Will always show a dialog when opening a URL. + /// + AlwaysWarn, + + /// + /// Will never show a dialog when opening a URL. + /// + NeverWarn, + } +} diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs index 0e6f6f0bf6..43452a768c 100644 --- a/osu.Game/Online/Chat/NowPlayingCommand.cs +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -33,6 +34,7 @@ namespace osu.Game.Online.Chat private IBindable currentRuleset { get; set; } = null!; private readonly Channel? target; + private IBindable userActivity = null!; /// /// Creates a new to post the currently-playing beatmap to a parenting . @@ -43,6 +45,12 @@ namespace osu.Game.Online.Chat this.target = target; } + [BackgroundDependencyLoader] + private void load(SessionStatics session) + { + userActivity = session.GetBindable(Static.UserOnlineActivity); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -52,7 +60,7 @@ namespace osu.Game.Online.Chat int beatmapOnlineID; string beatmapDisplayTitle; - switch (api.Activity.Value) + switch (userActivity.Value) { case UserActivity.InGame game: verb = "playing"; @@ -87,19 +95,19 @@ namespace osu.Game.Online.Chat string getBeatmapPart() { - return beatmapOnlineID > 0 ? $"[{api.WebsiteRootUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle; + return beatmapOnlineID > 0 ? $"[{api.Endpoints.WebsiteUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle; } string getRulesetPart() { - if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + if (userActivity.Value is not UserActivity.InGame) return string.Empty; return $"<{currentRuleset.Value.Name}>"; } string getModPart() { - if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + if (userActivity.Value is not UserActivity.InGame) return string.Empty; if (selectedMods.Value.Count == 0) { diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 187191d232..667ef072a9 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -21,18 +20,18 @@ using osuTK.Input; namespace osu.Game.Online.Chat { /// - /// Display a chat channel in an insolated region. + /// Display a chat channel in an isolated region. /// public partial class StandAloneChatDisplay : CompositeDrawable { [Cached] - public readonly Bindable Channel = new Bindable(); + public readonly Bindable Channel = new Bindable(); - protected readonly ChatTextBox TextBox; + protected readonly ChatTextBox? TextBox; - private ChannelManager channelManager; + private ChannelManager? channelManager; - private StandAloneDrawableChannel drawableChannel; + private StandAloneDrawableChannel? drawableChannel; private readonly bool postingTextBox; @@ -93,6 +92,8 @@ namespace osu.Game.Online.Chat private void postMessage(TextBox sender, bool newText) { + Debug.Assert(TextBox != null); + string text = TextBox.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) @@ -106,9 +107,9 @@ namespace osu.Game.Online.Chat TextBox.Text = string.Empty; } - protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); + protected virtual ChatLine? CreateMessage(Message message) => new StandAloneMessage(message); - private void channelChanged(ValueChangedEvent e) + private void channelChanged(ValueChangedEvent e) { drawableChannel?.Expire(); @@ -128,8 +129,8 @@ namespace osu.Game.Online.Chat public partial class ChatTextBox : HistoryTextBox { - public Action Focus; - public Action FocusLost; + public Action? Focus; + public Action? FocusLost; protected override bool OnKeyDown(KeyDownEvent e) { @@ -171,14 +172,14 @@ namespace osu.Game.Online.Chat public partial class StandAloneDrawableChannel : DrawableChannel { - public Func CreateChatLineAction; + public Func? CreateChatLineAction; public StandAloneDrawableChannel(Channel channel) : base(channel) { } - protected override ChatLine CreateChatLine(Message m) => CreateChatLineAction(m); + protected override ChatLine? CreateChatLine(Message m) => CreateChatLineAction?.Invoke(m) ?? null; protected override DaySeparator CreateDaySeparator(DateTimeOffset time) => new StandAloneDaySeparator(time); } diff --git a/osu.Game/Online/DevelopmentEndpointConfiguration.cs b/osu.Game/Online/DevelopmentEndpointConfiguration.cs index 5f3c353f4d..e36e36ee9f 100644 --- a/osu.Game/Online/DevelopmentEndpointConfiguration.cs +++ b/osu.Game/Online/DevelopmentEndpointConfiguration.cs @@ -7,12 +7,13 @@ namespace osu.Game.Online { public DevelopmentEndpointConfiguration() { - WebsiteRootUrl = APIEndpointUrl = @"https://dev.ppy.sh"; + WebsiteUrl = APIUrl = @"https://dev.ppy.sh"; APIClientSecret = @"3LP2mhUrV89xxzD1YKNndXHEhWWCRLPNKioZ9ymT"; APIClientID = "5"; - SpectatorEndpointUrl = $@"{APIEndpointUrl}/signalr/spectator"; - MultiplayerEndpointUrl = $@"{APIEndpointUrl}/signalr/multiplayer"; - MetadataEndpointUrl = $@"{APIEndpointUrl}/signalr/metadata"; + SpectatorUrl = $@"{APIUrl}/signalr/spectator"; + MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer"; + MetadataUrl = $@"{APIUrl}/signalr/metadata"; + BeatmapSubmissionServiceUrl = $@"{APIUrl}/beatmap-submission"; } } } diff --git a/osu.Game/Online/EndpointConfiguration.cs b/osu.Game/Online/EndpointConfiguration.cs index bd3c945124..2d5ea32345 100644 --- a/osu.Game/Online/EndpointConfiguration.cs +++ b/osu.Game/Online/EndpointConfiguration.cs @@ -8,16 +8,6 @@ namespace osu.Game.Online /// public class EndpointConfiguration { - /// - /// The base URL for the website. - /// - public string WebsiteRootUrl { get; set; } = string.Empty; - - /// - /// The endpoint for the main (osu-web) API. - /// - public string APIEndpointUrl { get; set; } = string.Empty; - /// /// The OAuth client secret. /// @@ -28,19 +18,34 @@ namespace osu.Game.Online /// public string APIClientID { get; set; } = string.Empty; + /// + /// The base URL for the website. Does not include a trailing slash. + /// + public string WebsiteUrl { get; set; } = string.Empty; + + /// + /// The endpoint for the main (osu-web) API. Does not include a trailing slash. + /// + public string APIUrl { get; set; } = string.Empty; + + /// + /// The root URL for the service handling beatmap submission. Does not include a trailing slash. + /// + public string? BeatmapSubmissionServiceUrl { get; set; } + /// /// The endpoint for the SignalR spectator server. /// - public string SpectatorEndpointUrl { get; set; } = string.Empty; + public string SpectatorUrl { get; set; } = string.Empty; /// /// The endpoint for the SignalR multiplayer server. /// - public string MultiplayerEndpointUrl { get; set; } = string.Empty; + public string MultiplayerUrl { get; set; } = string.Empty; /// /// The endpoint for the SignalR metadata server. /// - public string MetadataEndpointUrl { get; set; } = string.Empty; + public string MetadataUrl { get; set; } = string.Empty; } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs new file mode 100644 index 0000000000..a73c705d76 --- /dev/null +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -0,0 +1,235 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; + +namespace osu.Game.Online +{ + public partial class FriendPresenceNotifier : Component + { + [Resolved] + private INotificationOverlay notifications { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); + + private readonly IBindableList friends = new BindableList(); + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + + private readonly HashSet onlineAlertQueue = new HashSet(); + private readonly HashSet offlineAlertQueue = new HashSet(); + + private double? lastOnlineAlertTime; + private double? lastOfflineAlertTime; + + protected override void LoadComplete() + { + base.LoadComplete(); + + config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + + friends.BindTo(api.Friends); + friends.BindCollectionChanged(onFriendsChanged, true); + + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresenceChanged, true); + } + + protected override void Update() + { + base.Update(); + + alertOnlineUsers(); + alertOfflineUsers(); + } + + private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (APIRelation friend in e.NewItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + if (friendPresences.TryGetValue(friend.TargetID, out _)) + markUserOnline(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (APIRelation friend in e.OldItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + onlineAlertQueue.Remove(user); + offlineAlertQueue.Remove(user); + } + + break; + } + } + + private void onFriendPresenceChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int friendId, _) in e.NewItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOnline(user); + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int friendId, _) in e.OldItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOffline(user); + } + + break; + } + } + + private void markUserOnline(APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + + private void markUserOffline(APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + + private void alertOnlineUsers() + { + if (onlineAlertQueue.Count == 0) + return; + + if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) + return; + + if (!notifyOnFriendPresenceChange.Value) + { + lastOnlineAlertTime = null; + return; + } + + notifications.Post(new FriendOnlineNotification(onlineAlertQueue.ToArray())); + + onlineAlertQueue.Clear(); + lastOnlineAlertTime = null; + } + + private void alertOfflineUsers() + { + if (offlineAlertQueue.Count == 0) + return; + + if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) + return; + + if (!notifyOnFriendPresenceChange.Value) + { + lastOfflineAlertTime = null; + return; + } + + notifications.Post(new FriendOfflineNotification(offlineAlertQueue.ToArray())); + + offlineAlertQueue.Clear(); + lastOfflineAlertTime = null; + } + + public partial class FriendOnlineNotification : SimpleNotification + { + private readonly ICollection users; + + public FriendOnlineNotification(ICollection users) + { + this.users = users; + Transient = true; + IsImportant = false; + Icon = FontAwesome.Solid.User; + Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, ChannelManager channelManager, ChatOverlay chatOverlay) + { + IconColour = colours.GrayD; + Activated = () => + { + APIUser? singleUser = users.Count == 1 ? users.Single() : null; + + if (singleUser != null) + { + channelManager.OpenPrivateChannel(singleUser); + chatOverlay.Show(); + } + + return true; + }; + } + + public override string PopInSampleName => "UI/notification-friend-online"; + } + + private partial class FriendOfflineNotification : SimpleNotification + { + public FriendOfflineNotification(ICollection users) + { + Transient = true; + IsImportant = false; + Icon = FontAwesome.Solid.UserSlash; + Text = $"Offline: {string.Join(@", ", users.Select(u => u.Username))}"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) => IconColour = colours.Gray3; + + public override string PopInSampleName => "UI/notification-friend-offline"; + } + } +} diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 0fd9597ac0..021a2b3959 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -356,6 +356,9 @@ namespace osu.Game.Online.Leaderboards case LeaderboardState.NotSupporter: return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); + case LeaderboardState.NoTeam: + return new MessagePlaceholder(LeaderboardStrings.NoTeam); + case LeaderboardState.Retrieving: return null; @@ -363,7 +366,7 @@ namespace osu.Game.Online.Leaderboards return null; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state)); } } @@ -375,8 +378,8 @@ namespace osu.Game.Online.Leaderboards { base.UpdateAfterChildren(); - float fadeBottom = scrollContainer.Current + scrollContainer.DrawHeight; - float fadeTop = scrollContainer.Current + LeaderboardScore.HEIGHT; + float fadeBottom = (float)(scrollContainer.Current + scrollContainer.DrawHeight); + float fadeTop = (float)(scrollContainer.Current + LeaderboardScore.HEIGHT); if (!scrollContainer.IsScrolledToEnd()) fadeBottom -= LeaderboardScore.HEIGHT; diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 5651f01645..fb5bb225c0 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -54,6 +54,7 @@ namespace osu.Game.Online.Leaderboards private readonly int? rank; private readonly bool isOnlineScope; + private readonly bool highlightFriend; private Box background; private Container content; @@ -86,12 +87,13 @@ namespace osu.Game.Online.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } = null!; - public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true) + public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true, bool highlightFriend = true) { Score = score; this.rank = rank; this.isOnlineScope = isOnlineScope; + this.highlightFriend = highlightFriend; RelativeSizeAxes = Axes.X; Height = HEIGHT; @@ -101,6 +103,7 @@ namespace osu.Game.Online.Leaderboards private void load(IAPIProvider api, OsuColour colour) { var user = Score.User; + bool isUserFriend = api.Friends.Any(friend => friend.TargetID == user.OnlineID); statisticsLabels = GetStatistics(Score).Select(s => new ScoreComponentLabel(s)).ToList(); @@ -129,7 +132,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black, + Colour = (highlightFriend && isUserFriend) ? colour.Yellow : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black), Alpha = background_alpha, }, }, @@ -177,6 +180,7 @@ namespace osu.Game.Online.Leaderboards Height = 28, Direction = FillDirection.Horizontal, Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Bottom = -2 }, Children = new Drawable[] { flagBadgeAndDateContainer = new FillFlowContainer @@ -186,7 +190,7 @@ namespace osu.Game.Online.Leaderboards RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Spacing = new Vector2(5f, 0f), - Width = 87f, + Width = 130f, Masking = true, Children = new Drawable[] { @@ -196,6 +200,12 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.CentreLeft, Size = new Vector2(28, 20), }, + new UpdateableTeamFlag(user.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new DateLabel(Score.Date) { Anchor = Anchor.CentreLeft, @@ -203,15 +213,6 @@ namespace osu.Game.Online.Leaderboards }, }, }, - new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = edge_margin }, - Children = statisticsLabels - }, }, }, }, @@ -231,6 +232,7 @@ namespace osu.Game.Online.Leaderboards GlowColour = Color4Extensions.FromHex(@"83ccfa"), Current = scoreManager.GetBindableTotalScoreString(Score), Font = OsuFont.Numeric.With(size: 23), + Margin = new MarginPadding { Top = 1 }, }, RankContainer = new Container { @@ -247,13 +249,33 @@ namespace osu.Game.Online.Leaderboards }, }, }, - modsContainer = new FillFlowContainer + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = edge_margin }, + Children = statisticsLabels + }, + modsContainer = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(-10, 0), + Direction = FillDirection.Horizontal, + ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.34f) }) + }, + } }, }, }, @@ -321,7 +343,7 @@ namespace osu.Game.Online.Leaderboards private partial class ScoreComponentLabel : Container, IHasTooltip { - private const float icon_size = 20; + private const float icon_size = 16; private readonly FillFlowContainer content; public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); @@ -337,7 +359,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Right = 10 }, + Padding = new MarginPadding { Right = 5 }, Children = new Drawable[] { new Container @@ -372,7 +394,8 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Text = statistic.Value, - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, fixedWidth: true) + Spacing = new Vector2(-1, 0), + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold, fixedWidth: true) }, }, }; @@ -403,7 +426,7 @@ namespace osu.Game.Online.Leaderboards public DateLabel(DateTimeOffset date) : base(date) { - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, italics: true); + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold); } protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); @@ -429,11 +452,14 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); - if (Score.Mods.Length > 0 && songSelect != null) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); + // system mods should never be copied across regardless of anything. + var copyableMods = Score.Mods.Where(m => m.Type != ModType.System).ToArray(); + + if (copyableMods.Length > 0 && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = copyableMods)); if (Score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{Score.OnlineID}"))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); if (Score.Files.Count > 0) { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index ed3ee4d45e..e79aff9e81 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -219,20 +219,15 @@ namespace osu.Game.Online.Leaderboards } }; - string description = mod.SettingDescription; - - if (!string.IsNullOrEmpty(description)) + container.Add(new OsuSpriteText { - container.Add(new OsuSpriteText - { - RelativeSizeAxes = Axes.Y, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), - Text = mod.SettingDescription, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Top = 1 }, - }); - } + RelativeSizeAxes = Axes.Y, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = mod.IconTooltip, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Margin = new MarginPadding { Top = 1 }, + }); } } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs index 6b07500a98..dbd982acf2 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardState.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs @@ -14,5 +14,6 @@ namespace osu.Game.Online.Leaderboards NoScores, NotLoggedIn, NotSupporter, + NoTeam } } diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index 97c1bbde5f..a4251fae80 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -21,6 +21,11 @@ namespace osu.Game.Online.Metadata /// Task UserPresenceUpdated(int userId, UserPresence? status); + /// + /// Delivers and update of the of a friend with the supplied . + /// + Task FriendPresenceUpdated(int userId, UserPresence? presence); + /// /// Delivers an update of the current "daily challenge" status. /// Null value means there is no "daily challenge" currently active. diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 8a5fe1733e..0679191a52 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -3,9 +3,13 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Users; namespace osu.Game.Online.Metadata @@ -14,6 +18,9 @@ namespace osu.Game.Online.Metadata { public abstract IBindable IsConnected { get; } + [Resolved] + private IAPIProvider api { get; set; } = null!; + #region Beatmap metadata updates public abstract Task GetChangesSince(int queueId); @@ -33,37 +40,108 @@ namespace osu.Game.Online.Metadata #region User presence updates /// - /// Whether the client is currently receiving user presence updates from the server. + /// The information about the current user. /// - public abstract IBindable IsWatchingUserPresence { get; } + public abstract UserPresence LocalUserPresence { get; } /// /// Dictionary keyed by user ID containing all of the information about currently online users received from the server. /// - public abstract IBindableDictionary UserStates { get; } + public abstract IBindableDictionary UserPresences { get; } + + /// + /// Dictionary keyed by user ID containing all of the information about currently online friends received from the server. + /// + public abstract IBindableDictionary FriendPresences { get; } + + /// + /// Attempts to retrieve the presence of a user. + /// + /// + /// This will return data if the client is currently receiving presence data. See . + /// + /// The user ID. + /// The user presence, or null if not available or the user's offline. + public UserPresence? GetPresence(int userId) + { + if (userId == api.LocalUser.Value.OnlineID) + return LocalUserPresence; + + if (FriendPresences.TryGetValue(userId, out UserPresence presence)) + return presence; + + if (UserPresences.TryGetValue(userId, out presence)) + return presence; + + return null; + } - /// public abstract Task UpdateActivity(UserActivity? activity); - /// public abstract Task UpdateStatus(UserStatus? status); - /// - public abstract Task BeginWatchingUserPresence(); + private int userPresenceWatchCount; - /// - public abstract Task EndWatchingUserPresence(); + protected bool IsWatchingUserPresence + => Interlocked.CompareExchange(ref userPresenceWatchCount, userPresenceWatchCount, userPresenceWatchCount) > 0; + + /// + /// Signals to the server that we want to begin receiving status updates for all users. + /// + /// An which will end the session when disposed. + public IDisposable BeginWatchingUserPresence() => new UserPresenceWatchToken(this); + + Task IMetadataServer.BeginWatchingUserPresence() + { + if (Interlocked.Increment(ref userPresenceWatchCount) == 1) + return BeginWatchingUserPresenceInternal(); + + return Task.CompletedTask; + } + + Task IMetadataServer.EndWatchingUserPresence() + { + if (Interlocked.Decrement(ref userPresenceWatchCount) == 0) + return EndWatchingUserPresenceInternal(); + + return Task.CompletedTask; + } + + protected abstract Task BeginWatchingUserPresenceInternal(); + + protected abstract Task EndWatchingUserPresenceInternal(); - /// public abstract Task UserPresenceUpdated(int userId, UserPresence? presence); + public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence); + + private class UserPresenceWatchToken : IDisposable + { + private readonly IMetadataServer server; + private bool isDisposed; + + public UserPresenceWatchToken(IMetadataServer server) + { + this.server = server; + server.BeginWatchingUserPresence().FireAndForget(); + } + + public void Dispose() + { + if (isDisposed) + return; + + server.EndWatchingUserPresence().FireAndForget(); + isDisposed = true; + } + } + #endregion #region Daily Challenge public abstract IBindable DailyChallengeInfo { get; } - /// public abstract Task DailyChallengeUpdated(DailyChallengeInfo? info); #endregion diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index a3041c6753..6637fc8dba 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -20,34 +20,38 @@ namespace osu.Game.Online.Metadata { public override IBindable IsConnected { get; } = new Bindable(); - public override IBindable IsWatchingUserPresence => isWatchingUserPresence; - private readonly BindableBool isWatchingUserPresence = new BindableBool(); + public override UserPresence LocalUserPresence => localUserPresence; + private UserPresence localUserPresence; - public override IBindableDictionary UserStates => userStates; - private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary UserPresences => userPresences; + private readonly BindableDictionary userPresences = new BindableDictionary(); + + public override IBindableDictionary FriendPresences => friendPresences; + private readonly BindableDictionary friendPresences = new BindableDictionary(); public override IBindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); private readonly string endpoint; + [Resolved] + private IAPIProvider api { get; set; } = null!; + private IHubClientConnector? connector; - private Bindable lastQueueId = null!; - private IBindable localUser = null!; + private IBindable userStatus = null!; private IBindable userActivity = null!; - private IBindable? userStatus; private HubConnection? connection => connector?.CurrentConnection; public OnlineMetadataClient(EndpointConfiguration endpoints) { - endpoint = endpoints.MetadataEndpointUrl; + endpoint = endpoints.MetadataUrl; } [BackgroundDependencyLoader] - private void load(IAPIProvider api, OsuConfigManager config) + private void load(OsuConfigManager config, SessionStatics session) { // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. @@ -61,6 +65,7 @@ namespace osu.Game.Online.Metadata // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); + connection.On(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated); connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); connection.On(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested); @@ -70,25 +75,22 @@ namespace osu.Game.Online.Metadata IsConnected.BindValueChanged(isConnectedChanged, true); } - lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); - localUser = api.LocalUser.GetBoundCopy(); - userActivity = api.Activity.GetBoundCopy()!; + lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); + userStatus = config.GetBindable(OsuSetting.UserOnlineStatus); + userActivity = session.GetBindable(Static.UserOnlineActivity); } protected override void LoadComplete() { base.LoadComplete(); - localUser.BindValueChanged(_ => + + userStatus.BindValueChanged(status => { if (localUser.Value is not GuestUser) - { - userStatus = localUser.Value.Status.GetBoundCopy(); - userStatus.BindValueChanged(status => UpdateStatus(status.NewValue), true); - } - else - userStatus = null; + UpdateStatus(status.NewValue); }, true); + userActivity.BindValueChanged(activity => { if (localUser.Value is not GuestUser) @@ -104,17 +106,22 @@ namespace osu.Game.Online.Metadata { Schedule(() => { - isWatchingUserPresence.Value = false; - userStates.Clear(); + userPresences.Clear(); + friendPresences.Clear(); dailyChallengeInfo.Value = null; + localUserPresence = default; }); + return; } + if (IsWatchingUserPresence) + BeginWatchingUserPresenceInternal(); + if (localUser.Value is not GuestUser) { UpdateActivity(userActivity.Value); - UpdateStatus(userStatus?.Value); + UpdateStatus(userStatus.Value); } if (lastQueueId.Value >= 0) @@ -194,47 +201,65 @@ namespace osu.Game.Online.Metadata return connection.InvokeAsync(nameof(IMetadataServer.UpdateStatus), status); } + protected override Task BeginWatchingUserPresenceInternal() + { + if (connector?.IsConnected.Value != true) + return Task.CompletedTask; + + Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)); + } + + protected override Task EndWatchingUserPresenceInternal() + { + if (connector?.IsConnected.Value != true) + return Task.CompletedTask; + + Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); + + // must be scheduled before any remote calls to avoid mis-ordering. + Schedule(() => userPresences.Clear()); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)); + } + public override Task UserPresenceUpdated(int userId, UserPresence? presence) { Schedule(() => { if (presence?.Status != null) - userStates[userId] = presence.Value; + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = presence.Value; + else + userPresences[userId] = presence.Value; + } else - userStates.Remove(userId); + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = default; + else + userPresences.Remove(userId); + } }); return Task.CompletedTask; } - public override async Task BeginWatchingUserPresence() + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) { - if (connector?.IsConnected.Value != true) - throw new OperationCanceledException(); - - Debug.Assert(connection != null); - await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false); - Schedule(() => isWatchingUserPresence.Value = true); - Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network); - } - - public override async Task EndWatchingUserPresence() - { - try + Schedule(() => { - if (connector?.IsConnected.Value != true) - throw new OperationCanceledException(); + if (presence?.Status != null) + friendPresences[userId] = presence.Value; + else + friendPresences.Remove(userId); + }); - // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => userStates.Clear()); - Debug.Assert(connection != null); - await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); - Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); - } - finally - { - Schedule(() => isWatchingUserPresence.Value = false); - } + return Task.CompletedTask; } public override Task DailyChallengeUpdated(DailyChallengeInfo? info) diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 0452d8b79c..adb9b92614 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -95,6 +95,14 @@ namespace osu.Game.Online.Multiplayer /// The new beatmap availability state of the user. Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability); + /// + /// Signals that a user in this room changed their style. + /// + /// The ID of the user whose style changed. + /// The user's beatmap. + /// The user's ruleset. + Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId); + /// /// Signals that a user in this room changed their local mods. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs index f266c38b8b..0ee9fa54cd 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs @@ -10,6 +10,13 @@ namespace osu.Game.Online.Multiplayer /// public interface IMultiplayerLoungeServer { + /// + /// Request to create a multiplayer room. + /// + /// The room to create. + /// The created multiplayer room. + Task CreateRoom(MultiplayerRoom room); + /// /// Request to join a multiplayer room. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 55f00b447f..490973faa2 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -57,6 +57,13 @@ namespace osu.Game.Online.Multiplayer /// The proposed new beatmap availability state. Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's style in the currently joined room. + /// + /// The beatmap. + /// The ruleset. + Task ChangeUserStyle(int? beatmapId, int? rulesetId); + /// /// Change the local user's mods in the currently joined room. /// diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs index b76a1cc05d..860fb90258 100644 --- a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -9,5 +9,9 @@ namespace osu.Game.Online.Multiplayer [Serializable] public class InvalidPasswordException : HubException { + public InvalidPasswordException() + : base("Invalid password") + { + } } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs index ac3b9724cc..bf11713663 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs @@ -5,6 +5,7 @@ using MessagePack; namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { + [MessagePackObject] public class TeamVersusUserState : MatchUserState { [Key(0)] diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 998a34931d..57aaf68853 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -18,7 +18,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -52,6 +51,11 @@ namespace osu.Game.Online.Multiplayer /// public event Action? UserKicked; + /// + /// Invoked when the room's host is changed. + /// + public event Action? HostChanged; + /// /// Invoked when a new item is added to the playlist. /// @@ -167,60 +171,91 @@ namespace osu.Game.Online.Multiplayer private CancellationTokenSource? joinCancellationSource; /// - /// Joins the for a given API . + /// Creates and joins a described by an API . /// - /// The API . - /// An optional password to use for the join operation. - public async Task JoinRoom(Room room, string? password = null) + /// The API describing the room to create. + /// If the current user is already in another room. + public async Task CreateRoom(Room room) { if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + throw new InvalidOperationException("Cannot create a multiplayer room while already in one."); var cancellationSource = joinCancellationSource = new CancellationTokenSource(); await joinOrLeaveTaskChain.Add(async () => { - Debug.Assert(room.RoomID != null); - - // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value, password ?? room.Password).ConfigureAwait(false); - Debug.Assert(joinedRoom != null); - - // Populate users. - Debug.Assert(joinedRoom.Users != null); - await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); - - // Update the stored room (must be done on update thread for thread-safety). - await runOnUpdateThreadAsync(() => - { - Debug.Assert(Room == null); - - Room = joinedRoom; - APIRoom = room; - - Debug.Assert(joinedRoom.Playlist.Count > 0); - - APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); - APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); - - // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. - APIRoom.EndDate = null; - - Debug.Assert(LocalUser != null); - addUserToAPIRoom(LocalUser); - - foreach (var user in joinedRoom.Users) - updateUserPlayingState(user.UserID, user.State); - - updateLocalRoomSettings(joinedRoom.Settings); - - postServerShuttingDownNotification(); - - OnRoomJoined(); - }, cancellationSource.Token).ConfigureAwait(false); + var multiplayerRoom = await CreateRoomInternal(new MultiplayerRoom(room)).ConfigureAwait(false); + await setupJoinedRoom(room, multiplayerRoom, cancellationSource.Token).ConfigureAwait(false); }, cancellationSource.Token).ConfigureAwait(false); } + /// + /// Joins the for a given API . + /// + /// The API . + /// An optional password to use for the join operation. + /// If the current user is already in another room, or does not represent an active room. + public async Task JoinRoom(Room room, string? password = null) + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + + if (room.RoomID == null) + throw new InvalidOperationException("Cannot join an inactive room."); + + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); + + await joinOrLeaveTaskChain.Add(async () => + { + var multiplayerRoom = await JoinRoomInternal(room.RoomID.Value, password ?? room.Password).ConfigureAwait(false); + await setupJoinedRoom(room, multiplayerRoom, cancellationSource.Token).ConfigureAwait(false); + }, cancellationSource.Token).ConfigureAwait(false); + } + + /// + /// Performs post-join setup of a . + /// + /// The incoming API that was requested to be joined. + /// The resuling that was joined. + /// A token to cancel the process. + private async Task setupJoinedRoom(Room apiRoom, MultiplayerRoom joinedRoom, CancellationToken cancellationToken) + { + // Populate users. + await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); + if (joinedRoom.Host != null) + await PopulateUsers([joinedRoom.Host]).ConfigureAwait(false); + + // Update the stored room (must be done on update thread for thread-safety). + await runOnUpdateThreadAsync(() => + { + Debug.Assert(Room == null); + Debug.Assert(APIRoom == null); + + Room = joinedRoom; + APIRoom = apiRoom; + + APIRoom.RoomID = joinedRoom.RoomID; + APIRoom.ChannelId = joinedRoom.ChannelID; + APIRoom.Host = joinedRoom.Host?.User; + APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); + APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); + // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. + APIRoom.EndDate = null; + + Debug.Assert(LocalUser != null); + addUserToAPIRoom(LocalUser); + + foreach (var user in joinedRoom.Users) + updateUserPlayingState(user.UserID, user.State); + + updateLocalRoomSettings(joinedRoom.Settings); + + postServerShuttingDownNotification(); + + OnRoomJoined(); + }, cancellationToken).ConfigureAwait(false); + } + /// /// Fired when the room join sequence is complete /// @@ -228,16 +263,11 @@ namespace osu.Game.Online.Multiplayer { } - /// - /// Joins the with a given ID. - /// - /// The room ID. - /// An optional password to use when joining the room. - /// The joined . - protected abstract Task JoinRoom(long roomId, string? password = null); - public Task LeaveRoom() { + if (Room == null) + return Task.CompletedTask; + // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. // This includes the setting of Room itself along with the initial update of the room settings on join. joinCancellationSource?.Cancel(); @@ -261,6 +291,24 @@ namespace osu.Game.Online.Multiplayer }); } + /// + /// Creates the with the given settings. + /// + /// The room. + /// The joined + protected abstract Task CreateRoomInternal(MultiplayerRoom room); + + /// + /// Joins the with a given ID. + /// + /// The room ID. + /// An optional password to use when joining the room. + /// The joined . + protected abstract Task JoinRoomInternal(long roomId, string? password = null); + + /// + /// Leaves the currently-joined . + /// protected abstract Task LeaveRoomInternal(); public abstract Task InvitePlayer(int userId); @@ -359,6 +407,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task DisconnectInternal(); + public abstract Task ChangeUserStyle(int? beatmapId, int? rulesetId); + /// /// Change the local user's mods in the currently joined room. /// @@ -395,15 +445,17 @@ namespace osu.Game.Online.Multiplayer switch (state) { case MultiplayerRoomState.Open: - APIRoom.Status = APIRoom.HasPassword ? new RoomStatusOpenPrivate() : new RoomStatusOpen(); + APIRoom.Status = RoomStatus.Idle; break; + case MultiplayerRoomState.WaitingForLoad: case MultiplayerRoomState.Playing: - APIRoom.Status = new RoomStatusPlaying(); + APIRoom.Status = RoomStatus.Playing; break; case MultiplayerRoomState.Closed: - APIRoom.Status = new RoomStatusEnded(); + APIRoom.EndDate = DateTimeOffset.Now; + APIRoom.Status = RoomStatus.Idle; break; } @@ -435,18 +487,44 @@ namespace osu.Game.Online.Multiplayer }, false); } - Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => - handleUserLeft(user, UserLeft); + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + { + Scheduler.Add(() => handleUserLeft(user, UserLeft), false); + return Task.CompletedTask; + } Task IMultiplayerClient.UserKicked(MultiplayerRoomUser user) { - if (LocalUser == null) - return Task.CompletedTask; + Scheduler.Add(() => + { + if (LocalUser == null) + return; - if (user.Equals(LocalUser)) - LeaveRoom(); + if (user.Equals(LocalUser)) + LeaveRoom(); - return handleUserLeft(user, UserKicked); + handleUserLeft(user, UserKicked); + }, false); + + return Task.CompletedTask; + } + + private void handleUserLeft(MultiplayerRoomUser user, Action? callback) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (Room == null) + return; + + Room.Users.Remove(user); + PlayingUserIds.Remove(user.UserID); + + Debug.Assert(APIRoom != null); + APIRoom.RecentParticipants = APIRoom.RecentParticipants.Where(u => u.Id != user.UserID).ToArray(); + APIRoom.ParticipantCount--; + + callback?.Invoke(user); + RoomUpdated?.Invoke(); } async Task IMultiplayerClient.Invited(int invitedBy, long roomID, string password) @@ -493,27 +571,6 @@ namespace osu.Game.Online.Multiplayer APIRoom.ParticipantCount++; } - private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) - { - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Remove(user); - PlayingUserIds.Remove(user.UserID); - - Debug.Assert(APIRoom != null); - APIRoom.RecentParticipants = APIRoom.RecentParticipants.Where(u => u.Id != user.UserID).ToArray(); - APIRoom.ParticipantCount--; - - callback?.Invoke(user); - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - Task IMultiplayerClient.HostChanged(int userId) { Scheduler.Add(() => @@ -528,6 +585,7 @@ namespace osu.Game.Online.Multiplayer Room.Host = user; APIRoom.Host = user?.User; + HostChanged?.Invoke(user); RoomUpdated?.Invoke(); }, false); @@ -652,6 +710,25 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + public Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId) + { + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user style is mostly for display. + if (user == null) + return; + + user.BeatmapId = beatmapId; + user.RulesetId = rulesetId; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; + } + public Task UserModsChanged(int userId, IEnumerable mods) { Scheduler.Add(() => @@ -787,19 +864,22 @@ namespace osu.Game.Online.Multiplayer /// The s to populate. protected async Task PopulateUsers(IEnumerable multiplayerUsers) { - var request = new GetUsersRequest(multiplayerUsers.Select(u => u.UserID).Distinct().ToArray()); - - await API.PerformAsync(request).ConfigureAwait(false); - - if (request.Response == null) - return; - - Dictionary users = request.Response.Users.ToDictionary(user => user.Id); - - foreach (var multiplayerUser in multiplayerUsers) + foreach (int[] userChunk in multiplayerUsers.Select(u => u.UserID).Distinct().Chunk(GetUsersRequest.MAX_IDS_PER_REQUEST)) { - if (users.TryGetValue(multiplayerUser.UserID, out var user)) - multiplayerUser.User = user; + var request = new GetUsersRequest(userChunk); + + await API.PerformAsync(request).ConfigureAwait(false); + + if (request.Response == null) + return; + + Dictionary users = request.Response.Users.ToDictionary(user => user.Id); + + foreach (var multiplayerUser in multiplayerUsers) + { + if (users.TryGetValue(multiplayerUser.UserID, out var user)) + multiplayerUser.User = user; + } } } @@ -821,7 +901,6 @@ namespace osu.Game.Online.Multiplayer Room.Settings = settings; APIRoom.Name = Room.Settings.Name; APIRoom.Password = Room.Settings.Password; - APIRoom.Status = string.IsNullOrEmpty(Room.Settings.Password) ? new RoomStatusOpen() : new RoomStatusOpenPrivate(); APIRoom.Type = Room.Settings.MatchType; APIRoom.QueueMode = Room.Settings.QueueMode; APIRoom.AutoStartDuration = Room.Settings.AutoStartDuration; diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index d846e7f566..1cc5a8e70a 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; +using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; namespace osu.Game.Online.Multiplayer @@ -16,12 +17,8 @@ namespace osu.Game.Online.Multiplayer { if (t.IsFaulted) { - Exception? exception = t.Exception; - - if (exception is AggregateException ae) - exception = ae.InnerException; - - Debug.Assert(exception != null); + Debug.Assert(t.Exception != null); + Exception exception = t.Exception.AsSingular(); if (exception.GetHubExceptionMessage() is string message) // Hub exceptions generally contain something we can show the user directly. diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index 00048fa931..db1722af8c 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using MessagePack; using Newtonsoft.Json; using osu.Game.Online.Rooms; @@ -58,6 +59,12 @@ namespace osu.Game.Online.Multiplayer [Key(7)] public IList ActiveCountdowns { get; set; } = new List(); + /// + /// The ID of the chat channel for the room. + /// + [Key(8)] + public int ChannelID { get; set; } + [JsonConstructor] [SerializationConstructor] public MultiplayerRoom(long roomId) @@ -65,6 +72,27 @@ namespace osu.Game.Online.Multiplayer RoomID = roomId; } + public MultiplayerRoom(Room room) + { + RoomID = room.RoomID ?? 0; + ChannelID = room.ChannelId; + Settings = new MultiplayerRoomSettings(room); + Host = room.Host != null ? new MultiplayerRoomUser(room.Host.OnlineID) : null; + Playlist = room.Playlist.Select(p => new MultiplayerPlaylistItem(p)).ToArray(); + } + + /// + /// Retrieves the active as determined by the room's current settings. + /// + [IgnoreMember] + public MultiplayerPlaylistItem CurrentPlaylistItem => Playlist.Single(item => item.ID == Settings.PlaylistItemId); + + /// + /// Determines whether a user is able to add playlist items to this room. + /// + /// The user to check. + public bool CanAddPlaylistItems(MultiplayerRoomUser user) => user.Equals(Host) || Settings.QueueMode != QueueMode.HostOnly; + public override string ToString() => $"RoomID:{RoomID} Host:{Host?.UserID} Users:{Users.Count} State:{State} Settings: [{Settings}]"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index c73b02874e..c264ec1eef 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -35,6 +35,20 @@ namespace osu.Game.Online.Multiplayer [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; + public MultiplayerRoomSettings() + { + } + + public MultiplayerRoomSettings(Room room) + { + Name = room.Name; + Password = room.Password ?? string.Empty; + MatchType = room.Type; + QueueMode = room.QueueMode; + AutoStartDuration = room.AutoStartDuration; + AutoSkip = room.AutoSkip; + } + public bool Equals(MultiplayerRoomSettings? other) { if (ReferenceEquals(this, other)) return true; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index f769b4c805..499e84ce80 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -22,9 +22,6 @@ namespace osu.Game.Online.Multiplayer [Key(1)] public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle; - [Key(4)] - public MatchUserState? MatchState { get; set; } - /// /// The availability state of the current beatmap. /// @@ -37,6 +34,21 @@ namespace osu.Game.Online.Multiplayer [Key(3)] public IEnumerable Mods { get; set; } = Enumerable.Empty(); + [Key(4)] + public MatchUserState? MatchState { get; set; } + + /// + /// If not-null, a local override for this user's ruleset selection. + /// + [Key(5)] + public int? RulesetId; + + /// + /// If not-null, a local override for this user's beatmap selection. + /// + [Key(6)] + public int? BeatmapId; + [IgnoreMember] public APIUser? User { get; set; } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 40436d730e..02e9cd4ee8 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -32,7 +32,7 @@ namespace osu.Game.Online.Multiplayer public OnlineMultiplayerClient(EndpointConfiguration endpoints) { - endpoint = endpoints.MultiplayerEndpointUrl; + endpoint = endpoints.MultiplayerUrl; } [BackgroundDependencyLoader] @@ -60,6 +60,7 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.GameplayStarted), ((IMultiplayerClient)this).GameplayStarted); connection.On(nameof(IMultiplayerClient.GameplayAborted), ((IMultiplayerClient)this).GameplayAborted); connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On(nameof(IMultiplayerClient.UserStyleChanged), ((IMultiplayerClient)this).UserStyleChanged); connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); connection.On(nameof(IMultiplayerClient.MatchRoomStateChanged), ((IMultiplayerClient)this).MatchRoomStateChanged); @@ -75,7 +76,32 @@ namespace osu.Game.Online.Multiplayer } } - protected override async Task JoinRoom(long roomId, string? password = null) + protected override async Task CreateRoomInternal(MultiplayerRoom room) + { + if (!IsConnected.Value) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + + try + { + return await connection.InvokeAsync(nameof(IMultiplayerServer.CreateRoom), room).ConfigureAwait(false); + } + catch (HubException exception) + { + if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) + { + Debug.Assert(connector != null); + + await connector.Reconnect().ConfigureAwait(false); + return await CreateRoomInternal(room).ConfigureAwait(false); + } + + throw; + } + } + + protected override async Task JoinRoomInternal(long roomId, string? password = null) { if (!IsConnected.Value) throw new OperationCanceledException(); @@ -93,7 +119,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(connector != null); await connector.Reconnect().ConfigureAwait(false); - return await JoinRoom(roomId, password).ConfigureAwait(false); + return await JoinRoomInternal(roomId, password).ConfigureAwait(false); } throw; @@ -186,6 +212,16 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); } + public override Task ChangeUserStyle(int? beatmapId, int? rulesetId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserStyle), beatmapId, rulesetId); + } + public override Task ChangeUserMods(IEnumerable newMods) { if (!IsConnected.Value) diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 824da152b2..ce55b50d94 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -87,7 +87,7 @@ namespace osu.Game.Online break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } }); diff --git a/osu.Game/Online/ProductionEndpointConfiguration.cs b/osu.Game/Online/ProductionEndpointConfiguration.cs index 0244761b65..20583c8c7e 100644 --- a/osu.Game/Online/ProductionEndpointConfiguration.cs +++ b/osu.Game/Online/ProductionEndpointConfiguration.cs @@ -7,12 +7,13 @@ namespace osu.Game.Online { public ProductionEndpointConfiguration() { - WebsiteRootUrl = APIEndpointUrl = @"https://osu.ppy.sh"; + WebsiteUrl = APIUrl = @"https://osu.ppy.sh"; APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; APIClientID = "5"; - SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator"; - MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer"; - MetadataEndpointUrl = "https://spectator.ppy.sh/metadata"; + SpectatorUrl = "https://spectator.ppy.sh/spectator"; + MultiplayerUrl = "https://spectator.ppy.sh/multiplayer"; + MetadataUrl = "https://spectator.ppy.sh/metadata"; + BeatmapSubmissionServiceUrl = "https://bss.ppy.sh"; } } } diff --git a/osu.Game/Online/Rooms/CreateRoomRequest.cs b/osu.Game/Online/Rooms/CreateRoomRequest.cs index 63a3b7bfa8..5b2ea77aad 100644 --- a/osu.Game/Online/Rooms/CreateRoomRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomRequest.cs @@ -15,6 +15,9 @@ namespace osu.Game.Online.Rooms public CreateRoomRequest(Room room) { Room = room; + + // Also copy back to the source model, since it is likely to have been stored elsewhere. + Success += r => Room.CopyFrom(r); } protected override WebRequest CreateWebRequest() @@ -23,7 +26,6 @@ namespace osu.Game.Online.Rooms req.ContentType = "application/json"; req.Method = HttpMethod.Post; - req.AddRaw(JsonConvert.SerializeObject(Room)); return req; diff --git a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs index e0f91032fd..eb2879ba6c 100644 --- a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Rooms var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; req.AddParameter("version_hash", versionHash); + req.AddParameter("beatmap_id", beatmapInfo.OnlineID.ToString(CultureInfo.InvariantCulture)); req.AddParameter("beatmap_hash", beatmapInfo.MD5Hash); req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture)); return req; diff --git a/osu.Game/Online/Rooms/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index 7feb709acb..2d0d572e84 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -11,28 +11,33 @@ namespace osu.Game.Online.Rooms { public class GetRoomsRequest : APIRequest> { - private readonly RoomStatusFilter status; + private readonly RoomModeFilter mode; + private readonly RoomStatusFilter? status; private readonly string category; - public GetRoomsRequest(RoomStatusFilter status, string category) + public GetRoomsRequest(FilterCriteria filterCriteria) { - this.status = status; - this.category = category; + mode = filterCriteria.Mode; + category = filterCriteria.Category; + status = filterCriteria.Status; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - if (status != RoomStatusFilter.Open) - req.AddParameter("mode", status.ToString().ToSnakeCase().ToLowerInvariant()); + if (mode != RoomModeFilter.Open) + req.AddParameter(@"mode", mode.ToString().ToSnakeCase().ToLowerInvariant()); + + if (status != null) + req.AddParameter(@"status", status.Value.ToString().ToSnakeCase().ToLowerInvariant()); if (!string.IsNullOrEmpty(category)) - req.AddParameter("category", category); + req.AddParameter(@"category", category); return req; } - protected override string Target => "rooms"; + protected override string Target => @"rooms"; } } diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index dfc7a53fb2..610e887242 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -16,6 +16,9 @@ namespace osu.Game.Online.Rooms { Room = room; Password = password; + + // Also copy back to the source model, since it is likely to have been stored elsewhere. + Success += r => Room.CopyFrom(r); } protected override WebRequest CreateWebRequest() diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 8be703e620..3234e28166 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -56,9 +56,31 @@ namespace osu.Game.Online.Rooms [Key(10)] public double StarRating { get; set; } + /// + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset. + /// + [Key(11)] + public bool Freestyle { get; set; } + [SerializationConstructor] public MultiplayerPlaylistItem() { } + + public MultiplayerPlaylistItem(PlaylistItem item) + { + ID = item.ID; + OwnerID = item.OwnerID; + BeatmapID = item.Beatmap.OnlineID; + BeatmapChecksum = item.Beatmap.MD5Hash; + RulesetID = item.RulesetID; + RequiredMods = item.RequiredMods.ToArray(); + AllowedMods = item.AllowedMods.ToArray(); + Expired = item.Expired; + PlaylistOrder = item.PlaylistOrder ?? 0; + PlayedAt = item.PlayedAt; + StarRating = item.Beatmap.StarRating; + Freestyle = item.Freestyle; + } } } diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index faa66c571d..74eaea8dbc 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -77,11 +77,17 @@ namespace osu.Game.Online.Rooms [CanBeNull] public MultiplayerScoresAround ScoresAround { get; set; } - public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) + [JsonProperty("ruleset_id")] + public int RulesetId { get; set; } + + [JsonProperty("beatmap_id")] + public int BeatmapId { get; set; } + + public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, [NotNull] BeatmapInfo beatmap) { - var ruleset = rulesets.GetRuleset(playlistItem.RulesetID); + var ruleset = rulesets.GetRuleset(RulesetId); if (ruleset == null) - throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {playlistItem.RulesetID}"); + throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {RulesetId}"); var rulesetInstance = ruleset.CreateInstance(); @@ -91,7 +97,7 @@ namespace osu.Game.Online.Rooms TotalScore = TotalScore, MaxCombo = MaxCombo, BeatmapInfo = beatmap, - Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {playlistItem.RulesetID} not found locally"), + Ruleset = ruleset, Passed = Passed, Statistics = Statistics, MaximumStatistics = MaximumStatistics, diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 47d4e163bf..817b42f503 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -67,6 +67,12 @@ namespace osu.Game.Online.Rooms set => Beatmap = new APIBeatmap { OnlineID = value }; } + /// + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset. + /// + [JsonProperty("freestyle")] + public bool Freestyle { get; set; } + /// /// A beatmap representing this playlist item. /// In many cases, this will *not* contain any usable information apart from OnlineID. @@ -101,6 +107,7 @@ namespace osu.Game.Online.Rooms PlayedAt = item.PlayedAt; RequiredMods = item.RequiredMods.ToArray(); AllowedMods = item.AllowedMods.ToArray(); + Freestyle = item.Freestyle; } public void MarkInvalid() => valid.Value = false; @@ -120,18 +127,19 @@ namespace osu.Game.Online.Rooms #endregion - public PlaylistItem With(Optional id = default, Optional beatmap = default, Optional playlistOrder = default) + public PlaylistItem With(Optional id = default, Optional beatmap = default, Optional playlistOrder = default, Optional ruleset = default) { return new PlaylistItem(beatmap.GetOr(Beatmap)) { ID = id.GetOr(ID), OwnerID = OwnerID, - RulesetID = RulesetID, + RulesetID = ruleset.GetOr(RulesetID), Expired = Expired, PlaylistOrder = playlistOrder.GetOr(PlaylistOrder), PlayedAt = PlayedAt, AllowedMods = AllowedMods, RequiredMods = RequiredMods, + Freestyle = Freestyle, valid = { Value = Valid.Value }, }; } @@ -143,6 +151,7 @@ namespace osu.Game.Online.Rooms && Expired == other.Expired && PlaylistOrder == other.PlaylistOrder && AllowedMods.SequenceEqual(other.AllowedMods) - && RequiredMods.SequenceEqual(other.RequiredMods); + && RequiredMods.SequenceEqual(other.RequiredMods) + && Freestyle == other.Freestyle; } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index e1813c7e4e..e965f9c187 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -6,12 +6,10 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using Newtonsoft.Json; using osu.Game.IO.Serialization.Converters; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms.RoomStatuses; namespace osu.Game.Online.Rooms { @@ -244,11 +242,11 @@ namespace osu.Game.Online.Rooms public int ChannelId { get => channelId; - private set => SetField(ref channelId, value); + set => SetField(ref channelId, value); } /// - /// The current room status. + /// The current status of the room. /// public RoomStatus Status { @@ -265,18 +263,6 @@ namespace osu.Game.Online.Rooms set => SetField(ref availability, value); } - [OnDeserialized] - private void onDeserialised(StreamingContext context) - { - // API doesn't populate status so let's do it here. - if (EndDate != null && DateTimeOffset.Now >= EndDate) - Status = new RoomStatusEnded(); - else if (HasPassword) - Status = new RoomStatusOpenPrivate(); - else - Status = new RoomStatusOpen(); - } - [JsonProperty("id")] private long? roomId; @@ -349,12 +335,30 @@ namespace osu.Game.Online.Rooms [JsonProperty("channel_id")] private int channelId; - // Not serialised (see: GetRoomsRequest). - private RoomStatus status = new RoomStatusOpen(); + [JsonProperty("status")] + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + private RoomStatus status; // Not yet serialised (not implemented). private RoomAvailability availability; + public Room() + { + } + + public Room(MultiplayerRoom room) + { + RoomID = room.RoomID; + Name = room.Settings.Name; + Password = room.Settings.Password; + Type = room.Settings.MatchType; + QueueMode = room.Settings.QueueMode; + AutoStartDuration = room.Settings.AutoStartDuration; + AutoSkip = room.Settings.AutoSkip; + Host = room.Host != null ? new APIUser { Id = room.Host.UserID } : null; + Playlist = room.Playlist.Select(p => new PlaylistItem(p)).ToArray(); + } + /// /// Copies values from another into this one. /// @@ -375,6 +379,7 @@ namespace osu.Game.Online.Rooms Type = other.Type; MaxParticipants = other.MaxParticipants; ParticipantCount = other.ParticipantCount; + StartDate = other.StartDate; EndDate = other.EndDate; UserScore = other.UserScore; QueueMode = other.QueueMode; @@ -387,6 +392,15 @@ namespace osu.Game.Online.Rooms RecentParticipants = other.RecentParticipants; } + /// + /// Whether the room is no longer available. + /// + /// + /// This property does not update in real-time and needs to be queried periodically. + /// Subscribe to to be notified of any immediate changes. + /// + public bool HasEnded => DateTimeOffset.Now >= EndDate; + [JsonObject(MemberSerialization.OptIn)] public class RoomPlaylistItemStats { diff --git a/osu.Game/Online/Rooms/RoomStatus.cs b/osu.Game/Online/Rooms/RoomStatus.cs index 4b890b00b7..d048486f19 100644 --- a/osu.Game/Online/Rooms/RoomStatus.cs +++ b/osu.Game/Online/Rooms/RoomStatus.cs @@ -1,19 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osu.Game.Graphics; -using osuTK.Graphics; - namespace osu.Game.Online.Rooms { - public abstract class RoomStatus + public enum RoomStatus { - public abstract string Message { get; } - public abstract Color4 GetAppropriateColour(OsuColour colours); - - public override int GetHashCode() => GetType().GetHashCode(); - public override bool Equals(object obj) => GetType() == obj?.GetType(); + Idle, + Playing, } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs deleted file mode 100644 index 0fc27d26b8..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusEnded : RoomStatus - { - public override string Message => "Ended"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.YellowDarker; - } -} diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs deleted file mode 100644 index 5cc664cf36..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusOpen : RoomStatus - { - public override string Message => "Open"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenLight; - } -} diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs deleted file mode 100644 index d71e706c76..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusOpenPrivate : RoomStatus - { - public override string Message => "Open (Private)"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenDark; - } -} diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs deleted file mode 100644 index 4d0c93b8ab..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusPlaying : RoomStatus - { - public override string Message => "Playing"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Purple; - } -} diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 59a12b3bf1..757bb07ec8 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -44,6 +44,8 @@ namespace osu.Game.Online (typeof(UserActivity.EditingBeatmap), typeof(UserActivity)), (typeof(UserActivity.ModdingBeatmap), typeof(UserActivity)), (typeof(UserActivity.TestingBeatmap), typeof(UserActivity)), + (typeof(UserActivity.InDailyChallengeLobby), typeof(UserActivity)), + (typeof(UserActivity.PlayingDailyChallenge), typeof(UserActivity)), }; } } diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs index 2dc2283c23..2b73037cb8 100644 --- a/osu.Game/Online/Spectator/ISpectatorClient.cs +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -37,5 +37,17 @@ namespace osu.Game.Online.Spectator /// The ID of the user who achieved the score. /// The ID of the score. Task UserScoreProcessed(int userId, long scoreId); + + /// + /// Signals that another user has started watching this client. + /// + /// The information about the user who started watching. + Task UserStartedWatching(SpectatorUser[] user); + + /// + /// Signals that another user has ended watching this client + /// + /// The ID of the user who ended watching. + Task UserEndedWatching(int userId); } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 036cfa1d76..29d174f8e3 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -24,7 +24,7 @@ namespace osu.Game.Online.Spectator public OnlineSpectatorClient(EndpointConfiguration endpoints) { - endpoint = endpoints.SpectatorEndpointUrl; + endpoint = endpoints.SpectatorUrl; } [BackgroundDependencyLoader] @@ -42,6 +42,8 @@ namespace osu.Game.Online.Spectator connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); + connection.On(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching); + connection.On(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested); }; diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index fb7a3d13ca..76e5cb0404 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -36,14 +37,15 @@ namespace osu.Game.Online.Spectator public abstract IBindable IsConnected { get; } /// - /// The states of all users currently being watched. + /// The states of all users currently being watched by the local user. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual IBindableDictionary WatchedUserStates => watchedUserStates; /// - /// A global list of all players currently playing. + /// All users who are currently watching the local user. /// - public IBindableList PlayingUsers => playingUsers; + public IBindableList WatchingUsers => watchingUsers; /// /// Whether the local user is playing. @@ -53,6 +55,7 @@ namespace osu.Game.Online.Spectator /// /// Called whenever new frames arrive from the server. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual event Action? OnNewFrames; /// @@ -82,7 +85,7 @@ namespace osu.Game.Online.Spectator private readonly BindableDictionary watchedUserStates = new BindableDictionary(); - private readonly BindableList playingUsers = new BindableList(); + private readonly BindableList watchingUsers = new BindableList(); private readonly SpectatorState currentState = new SpectatorState(); private IBeatmap? currentBeatmap; @@ -125,8 +128,8 @@ namespace osu.Game.Online.Spectator } else { - playingUsers.Clear(); watchedUserStates.Clear(); + watchingUsers.Clear(); } }), true); } @@ -135,9 +138,6 @@ namespace osu.Game.Online.Spectator { Schedule(() => { - if (!playingUsers.Contains(userId)) - playingUsers.Add(userId); - if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; @@ -151,8 +151,6 @@ namespace osu.Game.Online.Spectator { Schedule(() => { - playingUsers.Remove(userId); - if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; @@ -179,6 +177,30 @@ namespace osu.Game.Online.Spectator return Task.CompletedTask; } + Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users) + { + Schedule(() => + { + foreach (var user in users) + { + if (!watchingUsers.Contains(user)) + watchingUsers.Add(user); + } + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserEndedWatching(int userId) + { + Schedule(() => + { + watchingUsers.RemoveAll(u => u.OnlineID == userId); + }); + + return Task.CompletedTask; + } + Task IStatefulUserHubClient.DisconnectRequested() { Schedule(() => DisconnectInternal()); diff --git a/osu.Game/Online/Spectator/SpectatorUser.cs b/osu.Game/Online/Spectator/SpectatorUser.cs new file mode 100644 index 0000000000..9c9563be70 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorUser.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; +using osu.Game.Users; + +namespace osu.Game.Online.Spectator +{ + [Serializable] + [MessagePackObject] + public class SpectatorUser : IUser, IEquatable + { + [Key(0)] + public int OnlineID { get; set; } + + [Key(1)] + public string Username { get; set; } = string.Empty; + + [IgnoreMember] + public CountryCode CountryCode => CountryCode.Unknown; + + [IgnoreMember] + public bool IsBot => false; + + public bool Equals(SpectatorUser? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return OnlineID == other.OnlineID; + } + + public override bool Equals(object? obj) => Equals(obj as SpectatorUser); + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => OnlineID; + } +} diff --git a/osu.Game/Online/TrustedDomainOnlineStore.cs b/osu.Game/Online/TrustedDomainOnlineStore.cs new file mode 100644 index 0000000000..2b47f159e6 --- /dev/null +++ b/osu.Game/Online/TrustedDomainOnlineStore.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; + +namespace osu.Game.Online +{ + public sealed class TrustedDomainOnlineStore : OnlineStore + { + protected override string GetLookupUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || !uri.Host.EndsWith(@".ppy.sh", StringComparison.OrdinalIgnoreCase)) + { + Logger.Log($@"Blocking resource lookup from external website: {url}", LoggingTarget.Network, LogLevel.Important); + return string.Empty; + } + + return url; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 2e3b989c4e..3381553970 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -18,7 +18,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Configuration; -using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; @@ -57,7 +56,6 @@ using osu.Game.Overlays.Notifications; using osu.Game.Overlays.OSD; using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; -using osu.Game.Overlays.Volume; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens; @@ -69,10 +67,12 @@ using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Seasonal; using osu.Game.Skinning; using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; +using osuTK; using osuTK.Graphics; using Sentry; @@ -87,9 +87,9 @@ namespace osu.Game { #if DEBUG // Different port allows running release and debug builds alongside each other. - public const int IPC_PORT = 44824; + public const string IPC_PIPE_NAME = "osu-lazer-debug"; #else - public const int IPC_PORT = 44823; + public const string IPC_PIPE_NAME = "osu-lazer"; #endif /// @@ -174,9 +174,14 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); - IBindable ILocalUserPlayInfo.PlayingState => playingState; + /// + /// Whether the back button is currently displayed. + /// + private readonly IBindable backButtonVisibility = new Bindable(); - private readonly Bindable playingState = new Bindable(); + IBindable ILocalUserPlayInfo.PlayingState => UserPlayingState; + + protected readonly Bindable UserPlayingState = new Bindable(); protected OsuScreenStack ScreenStack; @@ -207,6 +212,8 @@ namespace osu.Game private Bindable uiScale; + private Bindable configUserActivity; + private Bindable configSkin; private readonly string[] args; @@ -216,14 +223,17 @@ namespace osu.Game private readonly List visibleBlockingOverlays = new List(); + /// + /// Whether the game should be limited to only display officially licensed content. + /// + public virtual bool HideUnlicensedContent => false; + public OsuGame(string[] args = null) { this.args = args; forwardGeneralLogsToNotifications(); forwardTabletLogsToNotifications(); - - SentryLogger = new SentryLogger(this); } #region IOverlayManager @@ -297,7 +307,7 @@ namespace osu.Game protected override UserInputManager CreateUserInputManager() { var userInputManager = base.CreateUserInputManager(); - (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(playingState); + (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(UserPlayingState); return userInputManager; } @@ -309,12 +319,19 @@ namespace osu.Game private readonly List dragDropFiles = new List(); private ScheduledDelegate dragDropImportSchedule; + public override void SetupLogging(Storage gameStorage, Storage cacheStorage) + { + base.SetupLogging(gameStorage, cacheStorage); + SentryLogger = new SentryLogger(this, cacheStorage); + } + public override void SetHost(GameHost host) { base.SetHost(host); if (host.Window != null) { + host.Window.CursorState |= CursorState.Hidden; host.Window.DragDrop += path => { // on macOS/iOS, URL associations are handled via SDL_DROPFILE events. @@ -357,7 +374,10 @@ namespace osu.Game { SentryLogger.AttachUser(API.LocalUser); - dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); + if (SeasonalUIConfig.ENABLED) + dependencies.CacheAs(osuLogo = new OsuLogoChristmas { Alpha = 0 }); + else + dependencies.CacheAs(osuLogo = new OsuLogo { Alpha = 0 }); // bind config int to database RulesetInfo configRuleset = LocalConfig.GetBindable(OsuSetting.Ruleset); @@ -378,6 +398,8 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; + configUserActivity = SessionStatics.GetBindable(Static.UserOnlineActivity); + configSkin = LocalConfig.GetBindable(OsuSetting.Skin); // Transfer skin from config to realm instance once on startup. @@ -386,7 +408,7 @@ namespace osu.Game // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - playingState.BindValueChanged(p => + UserPlayingState.BindValueChanged(p => { BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; @@ -399,6 +421,7 @@ namespace osu.Game SelectedMods.BindValueChanged(modsChanged); Beatmap.BindValueChanged(beatmapChanged, true); + configUserActivity.BindValueChanged(_ => updateWindowTitle()); applySafeAreaConsiderations = LocalConfig.GetBindable(OsuSetting.SafeAreaConsiderations); applySafeAreaConsiderations.BindValueChanged(apply => SafeAreaContainer.SafeAreaOverrideEdges = apply.NewValue ? SafeAreaOverrideEdges : Edges.All, true); @@ -496,38 +519,13 @@ namespace osu.Game } }); - public void CopyUrlToClipboard(string url) => waitForReady(() => onScreenDisplay, _ => + public void CopyToClipboard(string value) => waitForReady(() => onScreenDisplay, _ => { - dependencies.Get().SetText(url); - onScreenDisplay.Display(new CopyUrlToast()); + dependencies.Get().SetText(value); + onScreenDisplay.Display(new CopiedToClipboardToast()); }); - public void OpenUrlExternally(string url, bool forceBypassExternalUrlWarning = false) => waitForReady(() => externalLinkOpener, _ => - { - bool isTrustedDomain; - - if (url.StartsWith('/')) - { - url = $"{API.WebsiteRootUrl}{url}"; - isTrustedDomain = true; - } - else - { - isTrustedDomain = url.StartsWith(API.WebsiteRootUrl, StringComparison.Ordinal); - } - - if (!url.CheckIsValidUrl()) - { - Notifications.Post(new SimpleErrorNotification - { - Text = NotificationsStrings.UnsupportedOrDangerousUrlProtocol(url), - }); - - return; - } - - externalLinkOpener.OpenUrlExternally(url, forceBypassExternalUrlWarning || isTrustedDomain); - }); + public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.Default) => waitForReady(() => externalLinkOpener, _ => externalLinkOpener.OpenUrlExternally(url, warnMode)); /// /// Open a specific channel in chat. @@ -817,6 +815,12 @@ namespace osu.Game protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + /// + /// Adjust the globally applied in every . + /// Useful for changing how the game handles different aspect ratios. + /// + public virtual Vector2 ScalingContainerTargetDrawSize { get; } = new Vector2(1024, 768); + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); #region Beatmap progression @@ -825,6 +829,35 @@ namespace osu.Game { beatmap.OldValue?.CancelAsyncLoad(); beatmap.NewValue?.BeginAsyncLoad(); + updateWindowTitle(); + } + + private void updateWindowTitle() + { + if (Host.Window == null) + return; + + string newTitle; + + switch (configUserActivity.Value) + { + default: + newTitle = Name; + break; + + case UserActivity.InGame: + case UserActivity.TestingBeatmap: + case UserActivity.WatchingReplay: + newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(true, false)}"; + break; + + case UserActivity.EditingBeatmap: + newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.Path ?? "new beatmap"}"; + break; + } + + if (newTitle != Host.Window.Title) + Host.Window.Title = newTitle; } private void modsChanged(ValueChangedEvent> mods) @@ -975,12 +1008,6 @@ namespace osu.Game AddRange(new Drawable[] { - new VolumeControlReceptor - { - RelativeSizeAxes = Axes.Both, - ActionRequested = action => volume.Adjust(action), - ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), - }, ScreenOffsetContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -1019,7 +1046,7 @@ namespace osu.Game if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) return; - if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowBackButton && !currentScreen.OnBackButton())) + if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowUserExit && !currentScreen.OnBackButton())) ScreenStack.Exit(); } }, @@ -1138,11 +1165,12 @@ namespace osu.Game loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); - loadComponentSingleFile(new DetachedBeatmapStore(), Add, true); + loadComponentSingleFile(new RealmDetachedBeatmapStore(), Add, true); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen)); + Add(new FriendPresenceNotifier()); // side overlays which cancel each other. var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay }; @@ -1189,6 +1217,14 @@ namespace osu.Game if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; + backButtonVisibility.ValueChanged += visible => + { + if (visible.NewValue) + BackButton.Show(); + else + BackButton.Hide(); + }; + // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); } @@ -1324,7 +1360,7 @@ namespace osu.Game IconColour = Colours.YellowDark, Activated = () => { - OpenUrlExternally("https://opentabletdriver.net/Tablets", true); + OpenUrlExternally("https://opentabletdriver.net/Tablets", LinkWarnMode.NeverWarn); return true; } })); @@ -1412,13 +1448,27 @@ namespace osu.Game public bool OnPressed(KeyBindingPressEvent e) { + switch (e.Action) + { + case GlobalAction.DecreaseVolume: + case GlobalAction.IncreaseVolume: + return volume.Adjust(e.Action); + } + + // All actions below this point don't allow key repeat. if (e.Repeat) return false; + // Wait until we're loaded at least to the intro before allowing various interactions. if (introScreen == null) return false; switch (e.Action) { + case GlobalAction.ToggleMute: + case GlobalAction.NextVolumeMeter: + case GlobalAction.PreviousVolumeMeter: + return volume.Adjust(e.Action); + case GlobalAction.ToggleFPSDisplay: fpsCounter.ToggleVisibility(); return true; @@ -1535,7 +1585,7 @@ namespace osu.Game GlobalCursorDisplay.ShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } - private void screenChanged(IScreen current, IScreen newScreen) + protected virtual void ScreenChanged([CanBeNull] IOsuScreen current, [CanBeNull] IOsuScreen newScreen) { SentrySdk.ConfigureScope(scope => { @@ -1551,10 +1601,10 @@ namespace osu.Game switch (current) { case Player player: - player.PlayingState.UnbindFrom(playingState); + player.PlayingState.UnbindFrom(UserPlayingState); // reset for sanity. - playingState.Value = LocalUserPlayingState.NotPlaying; + UserPlayingState.Value = LocalUserPlayingState.NotPlaying; break; } @@ -1571,7 +1621,7 @@ namespace osu.Game break; case Player player: - player.PlayingState.BindTo(playingState); + player.PlayingState.BindTo(UserPlayingState); break; default: @@ -1579,33 +1629,32 @@ namespace osu.Game break; } - if (current is IOsuScreen currentOsuScreen) + if (current != null) { - OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); - API.Activity.UnbindFrom(currentOsuScreen.Activity); + backButtonVisibility.UnbindFrom(current.BackButtonVisibility); + OverlayActivationMode.UnbindFrom(current.OverlayActivationMode); + configUserActivity.UnbindFrom(current.Activity); } - if (newScreen is IOsuScreen newOsuScreen) + // Bind to new screen. + if (newScreen != null) { - OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); - API.Activity.BindTo(newOsuScreen.Activity); + backButtonVisibility.BindTo(newScreen.BackButtonVisibility); + OverlayActivationMode.BindTo(newScreen.OverlayActivationMode); + configUserActivity.BindTo(newScreen.Activity); - GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput; + // Handle various configuration updates based on new screen settings. + GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newScreen.HideMenuCursorOnNonMouseInput; - if (newOsuScreen.HideOverlaysOnEnter) + if (newScreen.HideOverlaysOnEnter) CloseAllOverlays(); else Toolbar.Show(); - if (newOsuScreen.AllowBackButton) - BackButton.Show(); - else - BackButton.Hide(); - - if (newOsuScreen.ShowFooter) + if (newScreen.ShowFooter) { BackButton.Hide(); - ScreenFooter.SetButtons(newOsuScreen.CreateFooterButtons()); + ScreenFooter.SetButtons(newScreen.CreateFooterButtons()); ScreenFooter.Show(); } else @@ -1613,16 +1662,16 @@ namespace osu.Game ScreenFooter.SetButtons(Array.Empty()); ScreenFooter.Hide(); } - } - skinEditor.SetTarget((OsuScreen)newScreen); + skinEditor.SetTarget((OsuScreen)newScreen); + } } - private void screenPushed(IScreen lastScreen, IScreen newScreen) => screenChanged(lastScreen, newScreen); + private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen); private void screenExited(IScreen lastScreen, IScreen newScreen) { - screenChanged(lastScreen, newScreen); + ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen); if (newScreen == null) Exit(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index dc13924b4f..4087a8b71e 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -41,6 +41,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; @@ -73,8 +74,6 @@ namespace osu.Game [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { - public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv", ".mpg", ".wmv", ".m4v" }; - #if DEBUG public const string GAME_NAME = "osu! (development)"; #else @@ -109,6 +108,8 @@ namespace osu.Game public virtual EndpointConfiguration CreateEndpoints() => UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + protected override OnlineStore CreateOnlineStore() => new TrustedDomainOnlineStore(); + public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); /// @@ -279,7 +280,7 @@ namespace osu.Game dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); - largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore())); + largeStore.AddTextureSource(Host.CreateTextureLoaderStore(CreateOnlineStore())); dependencies.Cache(largeStore); dependencies.CacheAs(LocalConfig); @@ -296,7 +297,7 @@ namespace osu.Game EndpointConfiguration endpoints = CreateEndpoints(); - MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; + MessageFormatter.WebsiteRootUrl = endpoints.WebsiteUrl; frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); frameworkLocale.BindValueChanged(_ => updateLanguage()); @@ -316,6 +317,7 @@ namespace osu.Game dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); + dependencies.CacheAs(BeatmapManager); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); @@ -385,6 +387,10 @@ namespace osu.Game GlobalActionContainer globalBindings; + OsuMenuSamples menuSamples; + dependencies.Cache(menuSamples = new OsuMenuSamples()); + base.Content.Add(menuSamples); + base.Content.Add(SafeAreaContainer = new SafeAreaContainer { SafeAreaOverrideEdges = SafeAreaOverrideEdges, diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index fb6a5796a1..b2b672342e 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -18,6 +18,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Chat; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -213,7 +214,7 @@ namespace osu.Game.Overlays.AccountCreation if (!string.IsNullOrEmpty(errors.Message)) passwordDescription.AddErrors(new[] { errors.Message }); - game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true); + game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", LinkWarnMode.NeverWarn); } } else diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index 82fc5508f1..55cba33153 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -126,7 +126,7 @@ namespace osu.Game.Overlays break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index bab64165cb..72d7e0c752 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -29,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Any time the text box receives key events (even while masked). /// - public Action TypingStarted; + public Action? TypingStarted; public Bindable Query => textBox.Current; @@ -51,7 +49,7 @@ namespace osu.Game.Overlays.BeatmapListing public Bindable ExplicitContent => explicitContentFilter.Current; - public APIBeatmapSet BeatmapSet + public APIBeatmapSet? BeatmapSet { set { @@ -67,7 +65,7 @@ namespace osu.Game.Overlays.BeatmapListing } private readonly BeatmapSearchTextBox textBox; - private readonly BeatmapSearchMultipleSelectionFilterRow generalFilter; + private readonly BeatmapSearchGeneralFilterRow generalFilter; private readonly BeatmapSearchRulesetFilterRow modeFilter; private readonly BeatmapSearchFilterRow categoryFilter; private readonly BeatmapSearchFilterRow genreFilter; @@ -151,7 +149,7 @@ namespace osu.Game.Overlays.BeatmapListing categoryFilter.Current.Value = SearchCategory.Leaderboard; } - private IBindable allowExplicitContent; + private IBindable allowExplicitContent = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuConfigManager config) @@ -165,6 +163,13 @@ namespace osu.Game.Overlays.BeatmapListing }, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + generalFilter.Ruleset.BindTo(Ruleset); + } + public void TakeFocus() => textBox.TakeFocus(); private partial class BeatmapSearchTextBox : BasicSearchTextBox @@ -172,7 +177,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Any time the text box receives key events (even while masked). /// - public Action TextChanged; + public Action? TextChanged; protected override Color4 SelectionColour => Color4.Gray; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs index 2d56c60de6..b62836dfde 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -1,18 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Overlays.Dialog; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK.Graphics; using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -20,27 +26,97 @@ namespace osu.Game.Overlays.BeatmapListing { public partial class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow { + public readonly IBindable Ruleset = new Bindable(); + public BeatmapSearchGeneralFilterRow() : base(BeatmapsStrings.ListingSearchFiltersGeneral) { } - protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter(); + protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter + { + Ruleset = { BindTarget = Ruleset } + }; private partial class GeneralFilter : MultipleSelectionFilter { + public readonly IBindable Ruleset = new Bindable(); + protected override MultipleSelectionFilterTabItem CreateTabItem(SearchGeneral value) { - if (value == SearchGeneral.FeaturedArtists) - return new FeaturedArtistsTabItem(); + switch (value) + { + case SearchGeneral.Recommended: + return new RecommendedDifficultyTabItem + { + Ruleset = { BindTarget = Ruleset } + }; - return new MultipleSelectionFilterTabItem(value); + case SearchGeneral.FeaturedArtists: + return new FeaturedArtistsTabItem(); + + default: + return new MultipleSelectionFilterTabItem(value); + } } } - private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem + private partial class RecommendedDifficultyTabItem : MultipleSelectionFilterTabItem { - private Bindable disclaimerShown; + public readonly IBindable Ruleset = new Bindable(); + + [Resolved] + private DifficultyRecommender? recommender { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public RecommendedDifficultyTabItem() + : base(SearchGeneral.Recommended) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (recommender != null) + recommender.StarRatingUpdated += updateText; + + Ruleset.BindValueChanged(_ => updateText(), true); + } + + private void updateText() + { + // fallback to profile default game mode if beatmap listing mode filter is set to Any + // TODO: find a way to update `PlayMode` when the profile default game mode has changed + RulesetInfo? ruleset = Ruleset.Value.IsLegacyRuleset() ? Ruleset.Value : rulesets.GetRuleset(api.LocalUser.Value.PlayMode); + + if (ruleset == null) return; + + double? starRating = recommender?.GetRecommendedStarRatingFor(ruleset); + + if (starRating != null) + Text.Text = LocalisableString.Interpolate($"{Value.GetLocalisableDescription()} ({starRating.Value.FormatStarRating()})"); + else + Text.Text = Value.GetLocalisableDescription(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (recommender != null) + recommender.StarRatingUpdated -= updateText; + } + } + + private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem, IHasTooltip + { + private Bindable disclaimerShown = null!; public FeaturedArtistsTabItem() : base(SearchGeneral.FeaturedArtists) @@ -48,19 +124,38 @@ namespace osu.Game.Overlays.BeatmapListing } [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private SessionStatics sessionStatics { get; set; } + private OsuConfigManager config { get; set; } = null!; - [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + [Resolved] + private SessionStatics sessionStatics { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private OsuGame? game { get; set; } + + public LocalisableString TooltipText => BeatmapOverlayStrings.FeaturedArtistsTooltip; protected override void LoadComplete() { base.LoadComplete(); + config.BindWith(OsuSetting.BeatmapListingFeaturedArtistFilter, Active); disclaimerShown = sessionStatics.GetBindable(Static.FeaturedArtistDisclaimerShownOnce); + + // no need to show the disclaimer if the user already had it toggled off in config. + if (!Active.Value) + disclaimerShown.Value = true; + + if (game?.HideUnlicensedContent == true) + { + Enabled.Value = false; + Active.Disabled = true; + } } protected override Color4 ColourNormal => colours.Orange1; @@ -68,6 +163,9 @@ namespace osu.Game.Overlays.BeatmapListing protected override bool OnClick(ClickEvent e) { + if (!Enabled.Value) + return true; + if (!disclaimerShown.Value && dialogOverlay != null) { dialogOverlay.Push(new FeaturedArtistConfirmDialog(() => diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs index 958297b559..73af62c322 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -73,7 +73,10 @@ namespace osu.Game.Overlays.BeatmapListing private void currentChanged(object? sender, NotifyCollectionChangedEventArgs e) { foreach (var c in Children) - c.Active.Value = Current.Contains(c.Value); + { + if (!c.Active.Disabled) + c.Active.Value = Current.Contains(c.Value); + } } /// @@ -100,7 +103,7 @@ namespace osu.Game.Overlays.BeatmapListing protected partial class MultipleSelectionFilterTabItem : FilterTabItem { - private Drawable activeContent = null!; + private Container activeContent = null!; private Circle background = null!; public MultipleSelectionFilterTabItem(T value) @@ -160,7 +163,9 @@ namespace osu.Game.Overlays.BeatmapListing { Color4 colour = Active.Value ? ColourActive : ColourNormal; - if (IsHovered) + if (!Enabled.Value) + colour = colour.Darken(1f); + else if (IsHovered) colour = Active.Value ? colour.Darken(0.2f) : colour.Lighten(0.2f); if (Active.Value) diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index 8f4ecaa0f5..e357718103 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -57,7 +57,9 @@ namespace osu.Game.Overlays.BeatmapListing { base.LoadComplete(); + Enabled.BindValueChanged(_ => UpdateState()); UpdateState(); + FinishTransforms(true); } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs index d18e1c93c9..c9783d42dc 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs @@ -39,7 +39,6 @@ namespace osu.Game.Overlays.BeatmapSet }, textContainer = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: 14)) { - Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding(10), diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 5f021803b0..eea0b087eb 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Extensions; @@ -21,6 +22,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.BeatmapSet @@ -30,9 +32,7 @@ namespace osu.Game.Overlays.BeatmapSet private const float tile_icon_padding = 7; private const float tile_spacing = 2; - private readonly OsuSpriteText version, starRating, starRatingText; - private readonly LinkFlowContainer guestMapperContainer; - private readonly FillFlowContainer starRatingContainer; + private readonly LinkFlowContainer infoContainer; private readonly Statistic plays, favourites; public readonly DifficultiesContainer Difficulties; @@ -52,6 +52,9 @@ namespace osu.Game.Overlays.BeatmapSet } } + [Resolved] + private OsuColour colours { get; set; } = null!; + public BeatmapPicker() { RelativeSizeAxes = Axes.X; @@ -71,59 +74,13 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2), Bottom = 10 }, - OnLostHover = () => - { - showBeatmap(Beatmap.Value); - starRatingContainer.FadeOut(100); - }, + OnLostHover = () => showBeatmap(Beatmap.Value, withStarRating: false), }, - new FillFlowContainer + infoContainer = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 11)) { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f), - Children = new Drawable[] - { - version = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold) - }, - guestMapperContainer = new LinkFlowContainer(s => - s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 11)) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Bottom = 1 }, - }, - starRatingContainer = new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Alpha = 0, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2f, 0), - Margin = new MarginPadding { Bottom = 1 }, - Children = new[] - { - starRatingText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), - Text = BeatmapsetsStrings.ShowStatsStars, - }, - starRating = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), - Text = string.Empty, - }, - } - }, - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.BottomLeft, }, new FillFlowContainer { @@ -143,7 +100,7 @@ namespace osu.Game.Overlays.BeatmapSet Beatmap.ValueChanged += b => { - showBeatmap(b.NewValue); + showBeatmap(b.NewValue, withStarRating: Difficulties.Any(d => d.IsHovered)); updateDifficultyButtons(); }; } @@ -152,10 +109,8 @@ namespace osu.Game.Overlays.BeatmapSet private IBindable ruleset { get; set; } = null!; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - starRating.Colour = colours.Yellow; - starRatingText.Colour = colours.Yellow; updateDisplay(); } @@ -184,16 +139,12 @@ namespace osu.Game.Overlays.BeatmapSet State = DifficultySelectorState.NotSelected, OnHovered = beatmap => { - showBeatmap(beatmap); - starRating.Text = beatmap.StarRating.ToLocalisableString(@"0.00"); - starRatingContainer.FadeIn(100); + showBeatmap(beatmap, withStarRating: true); }, OnClicked = beatmap => { Beatmap.Value = beatmap; }, }); } - starRatingContainer.FadeOut(100); - // If a selection is already made, try and maintain it. if (Beatmap.Value != null) Beatmap.Value = Difficulties.FirstOrDefault(b => b.Beatmap.OnlineID == Beatmap.Value.OnlineID)?.Beatmap; @@ -207,22 +158,68 @@ namespace osu.Game.Overlays.BeatmapSet updateDifficultyButtons(); } - private void showBeatmap(APIBeatmap? beatmapInfo) + private void showBeatmap(APIBeatmap? beatmapInfo, bool withStarRating) { - guestMapperContainer.Clear(); + infoContainer.Clear(); - if (beatmapInfo?.AuthorID != BeatmapSet?.AuthorID) + infoContainer.AddText(beatmapInfo?.DifficultyName ?? string.Empty, s => s.Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold)); + infoContainer.AddArbitraryDrawable(Empty().With(e => e.Width = 5)); + + var beatmapOwners = beatmapInfo?.BeatmapOwners; + bool isHostDifficulty = beatmapOwners?.Length == 1 && beatmapOwners.First().Id == beatmapSet?.AuthorID; + + if (beatmapOwners != null && !isHostDifficulty) { - APIUser? user = BeatmapSet?.RelatedUsers?.SingleOrDefault(u => u.OnlineID == beatmapInfo?.AuthorID); + APIUser[] users = BeatmapSet?.RelatedUsers?.Where(u => beatmapOwners.Any(o => o.Id == u.OnlineID)).ToArray() ?? []; + int count = users.Length; - if (user != null) + switch (count) { - guestMapperContainer.AddText("mapped by "); - guestMapperContainer.AddUserLink(user); + case 0: + break; + + case 1: + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + infoContainer.AddUserLink(users[0]); + break; + + case 2: + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + infoContainer.AddUserLink(users[0]); + infoContainer.AddText(CommonStrings.ArrayAndTwoWordsConnector); + infoContainer.AddUserLink(users[1]); + break; + + default: + { + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + + for (int i = 0; i < count; i++) + { + infoContainer.AddUserLink(users[i]); + + if (i < count - 2) + infoContainer.AddText(CommonStrings.ArrayAndWordsConnector); + else if (i == count - 2) + infoContainer.AddText(CommonStrings.ArrayAndLastWordConnector); + } + + break; + } } } - version.Text = beatmapInfo?.DifficultyName ?? string.Empty; + if (withStarRating) + { + infoContainer.AddArbitraryDrawable(Empty().With(e => e.Width = 5)); + infoContainer.AddText( + LocalisableString.Interpolate($"{BeatmapsetsStrings.ShowStatsStars} {beatmapInfo?.StarRating.FormatStarRating()}"), + t => + { + t.Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold); + t.Colour = colours.Yellow; + }); + } } private void updateDifficultyButtons() diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index a50043f0f0..c72c2a6698 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -98,7 +98,7 @@ namespace osu.Game.Overlays.BeatmapSet { Vertical = BeatmapSetOverlay.Y_PADDING, Left = WaveOverlayContainer.HORIZONTAL_PADDING, - Right = WaveOverlayContainer.HORIZONTAL_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + Right = WaveOverlayContainer.HORIZONTAL_PADDING + BeatmapSetOverlay.RIGHT_WIDTH + 10, }, Children = new Drawable[] { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index cbdb2ea190..eab394c8f6 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; @@ -21,7 +20,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public partial class FavouriteButton : HeaderButton, IHasTooltip + public partial class FavouriteButton : HeaderButton { public readonly Bindable BeatmapSet = new Bindable(); @@ -32,7 +31,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly IBindable localUser = new Bindable(); - public LocalisableString TooltipText + public override LocalisableString TooltipText { get { diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs index 5cfe4a35b3..12fbc4c790 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -22,6 +22,7 @@ namespace osu.Game.Overlays.BeatmapSet AddItem(BeatmapLeaderboardScope.Global); AddItem(BeatmapLeaderboardScope.Country); AddItem(BeatmapLeaderboardScope.Friend); + AddItem(BeatmapLeaderboardScope.Team); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs index 29a696593d..b161ee49c6 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -41,6 +41,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores case BeatmapLeaderboardScope.Country: text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresCountry; break; + + case BeatmapLeaderboardScope.Team: + text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresTeam; + break; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs new file mode 100644 index 0000000000..0bd4a1334f --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osuTK; + +namespace osu.Game.Overlays.BeatmapSet.Scores +{ + public partial class NoTeamPlaceholder : Container + { + public NoTeamPlaceholder() + { + AutoSizeAxes = Axes.Both; + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = LeaderboardStrings.NoTeam, + }, + } + }; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs index 7cb119bf32..36f71be606 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs @@ -3,11 +3,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osuTK; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -30,7 +29,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = BeatmapsetsStrings.ShowScoreboardSupporterOnly, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), }, text = new LinkFlowContainer(t => t.Font = t.Font.With(size: 11)) { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index c70c41feed..be6ad49150 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -160,7 +160,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Size = new Vector2(19, 14), }, - username, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new UpdateableTeamFlag(score.User.Team) + { + Size = new Vector2(28, 14), + }, + username, + } + }, #pragma warning disable 618 new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"), #pragma warning restore 618 diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index b53b7826f3..9b9661f83d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -40,6 +41,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly LeaderboardModSelector modSelector; private readonly NoScoresPlaceholder noScoresPlaceholder; private readonly NotSupporterPlaceholder notSupporterPlaceholder; + private readonly NoTeamPlaceholder noTeamPlaceholder; [Resolved] private IAPIProvider api { get; set; } @@ -154,10 +156,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores AlwaysPresent = true, Margin = new MarginPadding { Vertical = 10 } }, + noTeamPlaceholder = new NoTeamPlaceholder + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Vertical = 10 }, + Alpha = 0, + }, notSupporterPlaceholder = new NotSupporterPlaceholder { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + Margin = new MarginPadding { Vertical = 10 }, Alpha = 0, }, new FillFlowContainer @@ -247,13 +257,21 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return; } - if ((scope.Value != BeatmapLeaderboardScope.Global || modSelector.SelectedMods.Count > 0) && !userIsSupporter) + if ((scope.Value == BeatmapLeaderboardScope.Team) && user.Value.Team == null) + { + Scores = null; + noTeamPlaceholder.Show(); + return; + } + + if (scope.Value.RequiresSupporter(modSelector.SelectedMods.Count > 0) && !userIsSupporter) { Scores = null; notSupporterPlaceholder.Show(); return; } + noTeamPlaceholder.Hide(); notSupporterPlaceholder.Hide(); Show(); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index e8833fa0a3..8e342b49c9 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly FontUsage smallFont = OsuFont.GetFont(size: 16); private readonly FontUsage largeFont = OsuFont.GetFont(size: 22, weight: FontWeight.Light); - private readonly TextColumn totalScoreColumn; + private readonly TotalScoreColumn totalScoreColumn; private readonly TextColumn accuracyColumn; private readonly TextColumn maxComboColumn; private readonly TextColumn ppColumn; @@ -67,7 +66,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Spacing = new Vector2(margin, 0), Children = new Drawable[] { - totalScoreColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width), + totalScoreColumn = new TotalScoreColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width), accuracyColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, largeFont, top_columns_min_width), maxComboColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, largeFont, top_columns_min_width) } @@ -226,7 +225,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private partial class TextColumn : InfoColumn, IHasCurrentValue + private partial class TextColumn : InfoColumn { private readonly OsuTextFlowContainer text; @@ -249,18 +248,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private Bindable current; - - public Bindable Current - { - get => current; - set - { - text.Clear(); - text.AddText(value.Value, t => t.Current = current = value); - } - } - public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) : this(title, new OsuTextFlowContainer(t => t.Font = font) { @@ -276,6 +263,28 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } + private partial class TotalScoreColumn : TextColumn + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public TotalScoreColumn(LocalisableString title, FontUsage font, float? minWidth = null) + : base(title, font, minWidth) + { + } + + public Bindable Current + { + get => current; + set => current.Current = value; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(_ => Text = current.Value, true); + } + } + private partial class ModsInfoColumn : InfoColumn { private readonly FillFlowContainer modsContainer; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 13ba9fb74b..14c9bedc67 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -27,7 +27,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly UpdateableAvatar avatar; private readonly LinkFlowContainer usernameText; private readonly DrawableDate achievedOn; + private readonly UpdateableFlag flag; + private readonly UpdateableTeamFlag teamFlag; public TopScoreUserSection() { @@ -112,12 +114,30 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, } }, - flag = new UpdateableFlag + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(19, 14), - Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + flag = new UpdateableFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(19, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + teamFlag = new UpdateableTeamFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(28, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + } }, } } @@ -139,6 +159,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { avatar.User = value.User; flag.CountryCode = value.User.CountryCode; + teamFlag.Team = value.User.Team; achievedOn.Date = value.Date; usernameText.Clear(); diff --git a/osu.Game/Overlays/Changelog/ChangelogEntry.cs b/osu.Game/Overlays/Changelog/ChangelogEntry.cs index 9c40440778..d6021972c6 100644 --- a/osu.Game/Overlays/Changelog/ChangelogEntry.cs +++ b/osu.Game/Overlays/Changelog/ChangelogEntry.cs @@ -82,7 +82,6 @@ namespace osu.Game.Overlays.Changelog }, title = new LinkFlowContainer { - Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.BottomLeft, diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs index 30273d2405..df1ea6c283 100644 --- a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using Humanizer; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -18,14 +16,12 @@ namespace osu.Game.Overlays.Changelog { if (stream.IsFeatured) Width *= 2; + + MainText = Value.DisplayName; + AdditionalText = Value.LatestBuild.DisplayVersion; + InfoText = Value.UserCount > 0 ? $"{"user".ToQuantity(Value.UserCount, "N0")} online" : default(LocalisableString); } - protected override LocalisableString MainText => Value.DisplayName; - - protected override LocalisableString AdditionalText => Value.LatestBuild.DisplayVersion; - - protected override LocalisableString InfoText => Value.UserCount > 0 ? $"{"user".ToQuantity(Value.UserCount, "N0")} online" : null; - protected override Color4 GetBarColour(OsuColour colours) => Value.Colour; } } diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index f027888962..03f6923455 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Framework.Testing; @@ -39,6 +40,7 @@ namespace osu.Game.Overlays.Chat.ChannelList public ChannelGroup AnnounceChannelGroup { get; private set; } = null!; public ChannelGroup PublicChannelGroup { get; private set; } = null!; + public ChannelGroup TeamChannelGroup { get; private set; } = null!; public ChannelGroup PrivateChannelGroup { get; private set; } = null!; private OsuScrollContainer scroll = null!; @@ -79,10 +81,12 @@ namespace osu.Game.Overlays.Chat.ChannelList RelativeSizeAxes = Axes.X, } }, - AnnounceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper(), false), - PublicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper(), false), + // cross-reference for icons: https://github.com/ppy/osu-web/blob/3c9e99eaf4bd9e73d2712f60d67f5bc95f9dfe2b/resources/js/chat/conversation-list.tsx#L13-L19 + AnnounceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper(), FontAwesome.Solid.Bullhorn, false), + PublicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper(), FontAwesome.Solid.Comments, false), selector = new ChannelListItem(ChannelListingChannel), - PrivateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper(), true), + TeamChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleTEAM.ToUpper(), FontAwesome.Solid.Users, false), + PrivateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper(), FontAwesome.Solid.Envelope, true), }, }, }, @@ -102,6 +106,7 @@ namespace osu.Game.Overlays.Chat.ChannelList }; selector.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); + updateVisibility(); } public void AddChannel(Channel channel) @@ -109,9 +114,13 @@ namespace osu.Game.Overlays.Chat.ChannelList if (channelMap.ContainsKey(channel)) return; - ChannelListItem item = new ChannelListItem(channel); + ChannelListItem item = new ChannelListItem(channel) + { + CanLeave = channel.Type != ChannelType.Team + }; item.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); - item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); + if (item.CanLeave) + item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); ChannelGroup group = getGroupFromChannel(channel); channelMap.Add(channel, item); @@ -156,6 +165,9 @@ namespace osu.Game.Overlays.Chat.ChannelList case ChannelType.Announce: return AnnounceChannelGroup; + case ChannelType.Team: + return TeamChannelGroup; + default: return PublicChannelGroup; } @@ -163,10 +175,8 @@ namespace osu.Game.Overlays.Chat.ChannelList private void updateVisibility() { - if (AnnounceChannelGroup.ItemFlow.Children.Count == 0) - AnnounceChannelGroup.Hide(); - else - AnnounceChannelGroup.Show(); + AnnounceChannelGroup.Alpha = AnnounceChannelGroup.ItemFlow.Any() ? 1 : 0; + TeamChannelGroup.Alpha = TeamChannelGroup.ItemFlow.Any() ? 1 : 0; } public partial class ChannelGroup : FillFlowContainer @@ -174,7 +184,7 @@ namespace osu.Game.Overlays.Chat.ChannelList private readonly bool sortByRecent; public readonly ChannelListItemFlow ItemFlow; - public ChannelGroup(LocalisableString label, bool sortByRecent) + public ChannelGroup(LocalisableString label, IconUsage icon, bool sortByRecent) { this.sortByRecent = sortByRecent; Direction = FillDirection.Vertical; @@ -184,11 +194,26 @@ namespace osu.Game.Overlays.Chat.ChannelList Children = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - Text = label, - Margin = new MarginPadding { Left = 18, Bottom = 5 }, - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = label, + Margin = new MarginPadding { Left = 18, Bottom = 5 }, + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + }, + new SpriteIcon + { + Icon = icon, + Size = new Vector2(12), + }, + } }, ItemFlow = new ChannelListItemFlow(sortByRecent) { diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index b197fe199d..6107f130ec 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -24,6 +24,8 @@ namespace osu.Game.Overlays.Chat.ChannelList public partial class ChannelListItem : OsuClickableContainer, IFilterable { public event Action? OnRequestSelect; + + public bool CanLeave { get; init; } = true; public event Action? OnRequestLeave; public readonly Channel Channel; @@ -160,7 +162,7 @@ namespace osu.Game.Overlays.Chat.ChannelList private ChannelListItemCloseButton? createCloseButton() { - if (isSelector) + if (isSelector || !CanLeave) return null; return new ChannelListItemCloseButton diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 6d8b21a7c5..b621b555b0 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -41,13 +41,13 @@ namespace osu.Game.Overlays.Chat #region Scroll handling - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = null) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) { base.OnUserScroll(value, animated, distanceDecay); updateTrackState(); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { base.ScrollTo(value, animated, distanceDecay); updateTrackState(); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index e386f2ac09..20c3b26b8b 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat } } - public IReadOnlyCollection DrawableContentFlow => drawableContentFlow; + public IEnumerable DrawableContentFlow => drawableContentFlow.Children; private const float font_size = 13; diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 41098ef823..2f0461eb40 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -116,7 +117,7 @@ namespace osu.Game.Overlays.Chat if (chatLine == null) return; - float center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; + double center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; scroll.ScrollTo(Math.Clamp(center, 0, scroll.ScrollableExtent)); chatLine.Highlight(); @@ -132,6 +133,7 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved -= pendingMessageResolved; } + [CanBeNull] protected virtual ChatLine CreateChatLine(Message m) => new ChatLine(m); protected virtual DaySeparator CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time); @@ -155,8 +157,13 @@ namespace osu.Game.Overlays.Chat { addDaySeparatorIfRequired(lastMessage, message); - ChatLineFlow.Add(CreateChatLine(message)); - lastMessage = message; + var chatLine = CreateChatLine(message); + + if (chatLine != null) + { + ChatLineFlow.Add(chatLine); + lastMessage = message; + } } var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); diff --git a/osu.Game/Overlays/Chat/DrawableChatUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs index 67191f6836..57338dde9f 100644 --- a/osu.Game/Overlays/Chat/DrawableChatUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -14,6 +15,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -22,7 +24,10 @@ using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens; +using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; using ChatStrings = osu.Game.Localisation.ChatStrings; @@ -69,6 +74,12 @@ namespace osu.Game.Overlays.Chat [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private MultiplayerClient? multiplayerClient { get; set; } + + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + [Resolved(canBeNull: true)] private ChannelManager? chatManager { get; set; } @@ -161,13 +172,10 @@ namespace osu.Game.Overlays.Chat if (user.Equals(APIUser.SYSTEM_USER)) return Array.Empty(); - List items = new List - { - new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, openUserProfile) - }; + if (user.Equals(api.LocalUser.Value)) + return Array.Empty(); - if (!user.Equals(api.LocalUser.Value)) - items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, openUserChannel)); + List items = new List(); if (currentChannel?.Value != null) { @@ -177,8 +185,29 @@ namespace osu.Game.Overlays.Chat })); } - if (!user.Equals(api.LocalUser.Value)) - items.Add(new OsuMenuItem("Report", MenuItemType.Destructive, ReportRequested)); + items.Add(new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, openUserProfile)); + + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, openUserChannel)); + + // We should probably be checking against an online state here. + // But we can't use MetadataClient.GetPresence because we may not be requesting/receiving presences. + // This isn't really too bad – worst case scenario the client will open spectator view and show the user as "offline". + { + items.Add(new OsuMenuItemSpacer()); + + items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => + { + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(user))); + })); + + if (multiplayerClient?.Room?.Users.All(u => u.UserID != user.Id) == true) + { + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(user.Id))); + } + } + + items.Add(new OsuMenuItemSpacer()); + items.Add(new OsuMenuItem(UsersStrings.ReportButtonText, MenuItemType.Destructive, ReportRequested)); return items.ToArray(); } diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index b11483e678..7f4ba3e2e2 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -228,7 +228,8 @@ namespace osu.Game.Overlays return true; case PlatformAction.DocumentClose: - channelManager.LeaveChannel(currentChannel.Value); + if (currentChannel.Value?.Type != ChannelType.Team) + channelManager.LeaveChannel(currentChannel.Value); return true; case PlatformAction.TabRestore: @@ -386,10 +387,8 @@ namespace osu.Game.Overlays { channelList.RemoveChannel(channel); - if (loadedChannels.ContainsKey(channel)) + if (loadedChannels.Remove(channel, out var loaded)) { - DrawableChannel loaded = loadedChannels[channel]; - loadedChannels.Remove(channel); // DrawableChannel removed from cache must be manually disposed loaded.Dispose(); } diff --git a/osu.Game/Overlays/Comments/CommentReportButton.cs b/osu.Game/Overlays/Comments/CommentReportButton.cs index e4d4d671da..09c0fd32d0 100644 --- a/osu.Game/Overlays/Comments/CommentReportButton.cs +++ b/osu.Game/Overlays/Comments/CommentReportButton.cs @@ -1,13 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -19,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.Comments { - public partial class CommentReportButton : CompositeDrawable, IHasPopover + public partial class CommentReportButton : CompositeDrawable, IHasPopover, IHasLineBaseHeight { private readonly Comment comment; @@ -88,5 +91,7 @@ namespace osu.Game.Overlays.Comments api.Queue(request); } + + public float LineBaseHeight => link.ChildrenOfType().FirstOrDefault()?.LineBaseHeight ?? DrawHeight; } } diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 921c1682f5..5e277357a9 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -244,7 +244,7 @@ namespace osu.Game.Overlays.Comments protected void OnSuccess(CommentBundle response) { commentCounter.Current.Value = response.Total; - newCommentEditor.CommentableMeta.Value = response.CommentableMeta.SingleOrDefault(m => m.Id == id.Value && m.Type == type.Value.ToString().ToSnakeCase().ToLowerInvariant()); + newCommentEditor.CommentableMeta.Value = response.CommentableMeta.SingleOrDefault(m => m.Id == id.Value && string.Equals(m.Type, type.Value.ToString().ToSnakeCase(), StringComparison.OrdinalIgnoreCase)); if (!response.Comments.Any()) { diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index d664a44be9..805d997998 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -419,8 +419,8 @@ namespace osu.Game.Overlays.Comments private void copyUrl() { - clipboard.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}"); - onScreenDisplay?.Display(new CopyUrlToast()); + clipboard.SetText($@"{api.Endpoints.APIUrl}/comments/{Comment.Id}"); + onScreenDisplay?.Display(new CopiedToClipboardToast()); } private void toggleReply() diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index ee277ff538..39df3ba22c 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -1,12 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -18,10 +14,8 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; -using osu.Game.Online.Spectator; using osu.Game.Resources.Localisation.Web; using osu.Game.Screens; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -36,21 +30,17 @@ namespace osu.Game.Overlays.Dashboard private const float search_textbox_height = 40; private const float padding = 10; - private readonly IBindableList playingUsers = new BindableList(); - private readonly IBindableDictionary onlineUsers = new BindableDictionary(); + private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); private readonly Dictionary userPanels = new Dictionary(); - private SearchContainer userFlow; - private BasicSearchTextBox searchTextBox; + private SearchContainer userFlow = null!; + private BasicSearchTextBox searchTextBox = null!; [Resolved] - private IAPIProvider api { get; set; } + private MetadataClient metadataClient { get; set; } = null!; [Resolved] - private SpectatorClient spectatorClient { get; set; } - - [Resolved] - private MetadataClient metadataClient { get; set; } + private UserLookupCache users { get; set; } = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -99,18 +89,12 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.Current.ValueChanged += text => userFlow.SearchTerm = text.NewValue; } - [Resolved] - private UserLookupCache users { get; set; } - protected override void LoadComplete() { base.LoadComplete(); - onlineUsers.BindTo(metadataClient.UserStates); - onlineUsers.BindCollectionChanged(onUserUpdated, true); - - playingUsers.BindTo(spectatorClient.PlayingUsers); - playingUsers.BindCollectionChanged(onPlayingUsersChanged, true); + onlineUserPresences.BindTo(metadataClient.UserPresences); + onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); } protected override void OnFocus(FocusEvent e) @@ -120,7 +104,7 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.TakeFocus(); } - private void onUserUpdated(object sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => + private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => { switch (e.Action) { @@ -133,42 +117,13 @@ namespace osu.Game.Overlays.Dashboard users.GetUserAsync(userId).ContinueWith(task => { - APIUser user = task.GetResultSafely(); - - if (user == null) - return; - - Schedule(() => - { - // explicitly refetch the user's status. - // things may have changed in between the time of scheduling and the time of actual execution. - if (onlineUsers.TryGetValue(userId, out var updatedStatus)) - { - user.Activity.Value = updatedStatus.Activity; - user.Status.Value = updatedStatus.Status; - } - - userFlow.Add(userPanels[userId] = createUserPanel(user)); - }); + if (task.GetResultSafely() is APIUser user) + Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); }); } break; - case NotifyDictionaryChangedAction.Replace: - Debug.Assert(e.NewItems != null); - - foreach (var kvp in e.NewItems) - { - if (userPanels.TryGetValue(kvp.Key, out var panel)) - { - panel.User.Activity.Value = kvp.Value.Activity; - panel.User.Status.Value = kvp.Value.Status; - } - } - - break; - case NotifyDictionaryChangedAction.Remove: Debug.Assert(e.OldItems != null); @@ -183,52 +138,26 @@ namespace osu.Game.Overlays.Dashboard } }); - private void onPlayingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - Debug.Assert(e.NewItems != null); - - foreach (int userId in e.NewItems) - { - if (userPanels.TryGetValue(userId, out var panel)) - panel.CanSpectate.Value = userId != api.LocalUser.Value.Id; - } - - break; - - case NotifyCollectionChangedAction.Remove: - Debug.Assert(e.OldItems != null); - - foreach (int userId in e.OldItems) - { - if (userPanels.TryGetValue(userId, out var panel)) - panel.CanSpectate.Value = false; - } - - break; - } - } - private OnlineUserPanel createUserPanel(APIUser user) => new OnlineUserPanel(user).With(panel => { panel.Anchor = Anchor.TopCentre; panel.Origin = Anchor.TopCentre; - panel.CanSpectate.Value = playingUsers.Contains(user.Id); }); public partial class OnlineUserPanel : CompositeDrawable, IFilterable { public readonly APIUser User; - public BindableBool CanSpectate { get; } = new BindableBool(); + private PurpleRoundedButton spectateButton = null!; public IEnumerable FilterTerms { get; } - [Resolved(canBeNull: true)] - private IPerformFromScreenRunner performer { get; set; } + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private MetadataClient? metadataClient { get; set; } public bool FilteringActive { set; get; } @@ -252,6 +181,27 @@ namespace osu.Game.Overlays.Dashboard AutoSizeAxes = Axes.Both; } + protected override void Update() + { + base.Update(); + + // TODO: we probably don't want to do this every frame. + var activity = metadataClient?.GetPresence(User.Id)?.Activity; + + switch (activity) + { + default: + spectateButton.Enabled.Value = false; + break; + + case UserActivity.InSoloGame: + case UserActivity.InMultiplayerGame: + case UserActivity.InPlaylistGame: + spectateButton.Enabled.Value = true; + break; + } + } + [BackgroundDependencyLoader] private void load() { @@ -269,19 +219,15 @@ namespace osu.Game.Overlays.Dashboard { RelativeSizeAxes = Axes.X, Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - // this is SHOCKING - Activity = { BindTarget = User.Activity }, - Status = { BindTarget = User.Status }, + Origin = Anchor.TopCentre }, - new PurpleRoundedButton + spectateButton = new PurpleRoundedButton { RelativeSizeAxes = Axes.X, Text = "Spectate", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Action = () => performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))), - Enabled = { BindTarget = CanSpectate } } } }, diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 3e393ced01..941d293d9d 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -1,15 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -17,48 +12,34 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; -using osu.Game.Users; -using osuTK; namespace osu.Game.Overlays.Dashboard.Friends { public partial class FriendDisplay : CompositeDrawable { - private List users = new List(); - - public List Users - { - get => users; - set - { - users = value; - onlineStreamControl.Populate(value); - } - } - - private CancellationTokenSource cancellationToken; - - [CanBeNull] - private SearchContainer currentContent; - - private FriendOnlineStreamControl onlineStreamControl; - private Box background; - private Box controlBackground; - private UserListToolbar userListToolbar; - private Container itemsPlaceholder; - private LoadingLayer loading; - private BasicSearchTextBox searchTextBox; - private readonly IBindableList apiFriends = new BindableList(); + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private FriendOnlineStreamControl streamControl = null!; + private Box background = null!; + private Box controlBackground = null!; + private UserListToolbar userListToolbar = null!; + private Container listContainer = null!; + private LoadingLayer loading = null!; + private BasicSearchTextBox searchTextBox = null!; + + private CancellationTokenSource? listLoadCancellation; + public FriendDisplay() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; } - [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider colourProvider, IAPIProvider api) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { InternalChild = new FillFlowContainer { @@ -86,7 +67,7 @@ namespace osu.Game.Overlays.Dashboard.Friends Top = 20, Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - FriendsOnlineStatusItem.PADDING }, - Child = onlineStreamControl = new FriendOnlineStreamControl(), + Child = streamControl = new FriendOnlineStreamControl(), } } }, @@ -157,7 +138,7 @@ namespace osu.Game.Overlays.Dashboard.Friends AutoSizeAxes = Axes.Y, Children = new Drawable[] { - itemsPlaceholder = new Container + listContainer = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -175,127 +156,55 @@ namespace osu.Game.Overlays.Dashboard.Friends background.Colour = colourProvider.Background4; controlBackground.Colour = colourProvider.Background5; - - apiFriends.BindTo(api.Friends); - apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true); } protected override void LoadComplete() { base.LoadComplete(); - onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); - userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); - userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); - searchTextBox.Current.BindValueChanged(_ => - { - if (currentContent.IsNotNull()) - currentContent.SearchTerm = searchTextBox.Current.Value; - }); + apiFriends.BindTo(api.Friends); + apiFriends.BindCollectionChanged((_, _) => reloadList()); + + userListToolbar.DisplayStyle.BindValueChanged(_ => reloadList(), true); } - private void recreatePanels() + private void reloadList() { - if (!users.Any()) - return; + listLoadCancellation?.Cancel(); + var cancellationSource = listLoadCancellation = new CancellationTokenSource(); - cancellationToken?.Cancel(); - - if (itemsPlaceholder.Any()) - loading.Show(); - - var sortedUsers = sortUsers(getUsersInCurrentGroup()); - - LoadComponentAsync(createTable(sortedUsers), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); - } - - private List getUsersInCurrentGroup() - { - switch (onlineStreamControl.Current.Value?.Status) + FriendsList? currentList = listContainer.SingleOrDefault(); + FriendsList newList = new FriendsList(userListToolbar.DisplayStyle.Value, apiFriends.Select(f => f.TargetUser!).ToArray()) { - default: - case OnlineStatus.All: - return users; - - case OnlineStatus.Offline: - return users.Where(u => !u.IsOnline).ToList(); - - case OnlineStatus.Online: - return users.Where(u => u.IsOnline).ToList(); - } - } - - private void addContentToPlaceholder(SearchContainer content) - { - loading.Hide(); - - var lastContent = currentContent; - - if (lastContent != null) - { - lastContent.FadeOut(100, Easing.OutQuint).Expire(); - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); - } - - itemsPlaceholder.Add(currentContent = content); - currentContent.FadeIn(200, Easing.OutQuint); - } - - private SearchContainer createTable(List users) - { - var style = userListToolbar.DisplayStyle.Value; - - return new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), - Children = users.Select(u => createUserPanel(u, style)).ToList(), - SearchTerm = searchTextBox.Current.Value, + OnlineStream = { BindTarget = streamControl.Current }, + SortCriteria = { BindTarget = userListToolbar.SortCriteria }, + SearchText = { BindTarget = searchTextBox.Current } }; - } - private UserPanel createUserPanel(APIUser user, OverlayPanelDisplayStyle style) - { - switch (style) + loading.Show(); + LoadComponentAsync(newList, finishLoad, cancellationSource.Token); + + void finishLoad(FriendsList list) { - default: - case OverlayPanelDisplayStyle.Card: - return new UserGridPanel(user).With(panel => - { - panel.Anchor = Anchor.TopCentre; - panel.Origin = Anchor.TopCentre; - panel.Width = 290; - }); + loading.Hide(); - case OverlayPanelDisplayStyle.List: - return new UserListPanel(user); + if (currentList != null) + { + currentList.FadeOut(100, Easing.OutQuint).Expire(); + currentList.Delay(25).Schedule(() => currentList.BypassAutoSizeAxes = Axes.Y); + } - case OverlayPanelDisplayStyle.Brick: - return new UserBrickPanel(user); - } - } - - private List sortUsers(List unsorted) - { - switch (userListToolbar.SortCriteria.Value) - { - default: - case UserSortCriteria.LastVisit: - return unsorted.OrderByDescending(u => u.LastVisit).ToList(); - - case UserSortCriteria.Rank: - return unsorted.OrderByDescending(u => u.Statistics.GlobalRank.HasValue).ThenBy(u => u.Statistics.GlobalRank ?? 0).ToList(); - - case UserSortCriteria.Username: - return unsorted.OrderBy(u => u.Username).ToList(); + listContainer.Add(newList); + newList.FadeIn(200, Easing.OutQuint); } } protected override void Dispose(bool isDisposing) { - cancellationToken?.Cancel(); base.Dispose(isDisposing); + + listLoadCancellation?.Cancel(); + listLoadCancellation?.Dispose(); } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs index 9f429c23d8..763571f605 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs @@ -1,30 +1,95 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; -using System.Linq; +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Users; namespace osu.Game.Overlays.Dashboard.Friends { - public partial class FriendOnlineStreamControl : OverlayStreamControl + public partial class FriendOnlineStreamControl : OverlayStreamControl { - protected override OverlayStreamItem CreateStreamItem(FriendStream value) => new FriendsOnlineStatusItem(value); + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + private readonly IBindableList apiFriends = new BindableList(); + private readonly BindableInt countAll = new BindableInt(); + private readonly BindableInt countOnline = new BindableInt(); + private readonly BindableInt countOffline = new BindableInt(); - public void Populate(List users) + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + public FriendOnlineStreamControl() { - Clear(); + Items = + [ + OnlineStatus.All, + OnlineStatus.Online, + OnlineStatus.Offline + ]; + } - int userCount = users.Count; - int onlineUsersCount = users.Count(user => user.IsOnline); + protected override void LoadComplete() + { + base.LoadComplete(); - AddItem(new FriendStream(OnlineStatus.All, userCount)); - AddItem(new FriendStream(OnlineStatus.Online, onlineUsersCount)); - AddItem(new FriendStream(OnlineStatus.Offline, userCount - onlineUsersCount)); + apiFriends.BindTo(api.Friends); + apiFriends.BindCollectionChanged((_, _) => updateCounts()); - Current.Value = Items.FirstOrDefault(); + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresencesChanged); + + updateCounts(); + } + + private void onFriendPresencesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + case NotifyDictionaryChangedAction.Remove: + updateCounts(); + break; + } + } + + private void updateCounts() + { + countAll.Value = apiFriends.Count; + countOnline.Value = 0; + countOffline.Value = 0; + + foreach (var user in apiFriends) + { + if (friendPresences.ContainsKey(user.TargetID)) + countOnline.Value++; + else + countOffline.Value++; + } + } + + protected override OverlayStreamItem CreateStreamItem(OnlineStatus value) + { + switch (value) + { + case OnlineStatus.All: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countAll } }; + + case OnlineStatus.Online: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countOnline } }; + + case OnlineStatus.Offline: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countOffline } }; + + default: + throw new ArgumentException(nameof(value)); + } } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs index 4abece9a8d..f791e34c8f 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs @@ -1,18 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; + namespace osu.Game.Overlays.Dashboard.Friends { public class FriendStream { - public OnlineStatus Status { get; } + public readonly BindableInt UserCount = new BindableInt(); + public readonly OnlineStatus Status; - public int Count { get; } - - public FriendStream(OnlineStatus status, int count) + public FriendStream(OnlineStatus status) { Status = status; - Count = count; } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs new file mode 100644 index 0000000000..955c2c046e --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public partial class FriendsList : CompositeDrawable + { + public readonly IBindable OnlineStream = new Bindable(); + public readonly IBindable SortCriteria = new Bindable(); + public readonly IBindable SearchText = new Bindable(); + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + private readonly OverlayPanelDisplayStyle style; + private readonly APIUser[] friends; + + private FriendsSearchContainer searchContainer = null!; + + public FriendsList(OverlayPanelDisplayStyle style, APIUser[] friends) + { + this.style = style; + this.friends = friends; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = searchContainer = new FriendsSearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), + SortCriteria = { BindTarget = SortCriteria }, + ChildrenEnumerable = friends.Select(createUserPanel) + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresencesChanged); + + SearchText.BindValueChanged(onSearchTextChanged, true); + OnlineStream.BindValueChanged(onFriendsStreamChanged, true); + } + + private void onFriendPresencesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + case NotifyDictionaryChangedAction.Remove: + updatePanelVisibilities(); + break; + } + } + + private void onSearchTextChanged(ValueChangedEvent search) + { + searchContainer.SearchTerm = search.NewValue; + } + + private void onFriendsStreamChanged(ValueChangedEvent stream) + { + updatePanelVisibilities(); + } + + private void updatePanelVisibilities() + { + foreach (var panel in searchContainer) + { + switch (OnlineStream.Value) + { + case OnlineStatus.All: + panel.CanBeShown.Value = true; + break; + + case OnlineStatus.Online: + panel.CanBeShown.Value = friendPresences.ContainsKey(panel.User.OnlineID); + break; + + case OnlineStatus.Offline: + panel.CanBeShown.Value = !friendPresences.ContainsKey(panel.User.OnlineID); + break; + } + } + } + + private FilterableUserPanel createUserPanel(APIUser user) + { + UserPanel panel; + + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + panel = new UserGridPanel(user); + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + panel.Width = 290; + break; + + case OverlayPanelDisplayStyle.List: + panel = new UserListPanel(user); + break; + + case OverlayPanelDisplayStyle.Brick: + panel = new UserBrickPanel(user); + break; + } + + return new FilterableUserPanel(panel); + } + + private partial class FriendsSearchContainer : SearchContainer + { + public readonly IBindable SortCriteria = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + SortCriteria.BindValueChanged(_ => InvalidateLayout(), true); + } + + public override IEnumerable FlowingChildren + { + get + { + IEnumerable panels = base.FlowingChildren.OfType(); + + switch (SortCriteria.Value) + { + default: + case UserSortCriteria.LastVisit: + // Todo: Last visit time is not currently updated according to realtime user presence. + return panels.OrderByDescending(panel => panel.User.LastVisit); + + case UserSortCriteria.Rank: + // Todo: Statistics are not currently updated according to realtime user statistics, but it's also not currently displayed in the panels. + return panels.OrderByDescending(panel => panel.User.Statistics.GlobalRank.HasValue).ThenBy(panel => panel.User.Statistics.GlobalRank ?? 0); + + case UserSortCriteria.Username: + return panels.OrderBy(panel => panel.User.Username); + } + } + } + } + + public partial class FilterableUserPanel : CompositeDrawable, IConditionalFilterable + { + public readonly Bindable CanBeShown = new Bindable(); + + public APIUser User => panel.User; + + private readonly UserPanel panel; + + public FilterableUserPanel(UserPanel panel) + { + this.panel = panel; + + Anchor = panel.Anchor; + Origin = panel.Origin; + RelativeSizeAxes = panel.RelativeSizeAxes; + AutoSizeAxes = panel.AutoSizeAxes; + + if (!AutoSizeAxes.HasFlagFast(Axes.X)) + Width = panel.Width; + + if (!AutoSizeAxes.HasFlagFast(Axes.Y)) + Height = panel.Height; + + InternalChild = panel; + } + + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + + IEnumerable IHasFilterTerms.FilterTerms => panel.FilterTerms; + + bool IFilterable.MatchingFilter + { + set + { + if (value) + Show(); + else + Hide(); + } + } + + bool IFilterable.FilteringActive { set { } } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs index 2aea631b7c..459592085b 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -2,27 +2,32 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Localisation; using osu.Game.Graphics; using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Friends { - public partial class FriendsOnlineStatusItem : OverlayStreamItem + public partial class FriendsOnlineStatusItem : OverlayStreamItem { - public FriendsOnlineStatusItem(FriendStream value) + public readonly IBindable UserCount = new Bindable(); + + public FriendsOnlineStatusItem(OnlineStatus value) : base(value) { + MainText = value.GetLocalisableDescription(); } - protected override LocalisableString MainText => Value.Status.GetLocalisableDescription(); - - protected override LocalisableString AdditionalText => Value.Count.ToString(); + protected override void LoadComplete() + { + base.LoadComplete(); + UserCount.BindValueChanged(count => AdditionalText = count.NewValue.ToString(), true); + } protected override Color4 GetBarColour(OsuColour colours) { - switch (Value.Status) + switch (Value) { case OnlineStatus.All: return Color4.White; @@ -34,7 +39,7 @@ namespace osu.Game.Overlays.Dashboard.Friends return Color4.Black; default: - throw new ArgumentException($@"{Value.Status} status does not provide a colour in {nameof(GetBarColour)}."); + throw new ArgumentException($@"{Value} status does not provide a colour in {nameof(GetBarColour)}."); } } } diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 1861f892bd..1912736135 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Online.Metadata; -using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.Friends; @@ -18,6 +17,7 @@ namespace osu.Game.Overlays private MetadataClient metadataClient { get; set; } = null!; private IBindable metadataConnected = null!; + private IDisposable? userPresenceWatchToken; public DashboardOverlay() : base(OverlayColourScheme.Purple) @@ -61,9 +61,12 @@ namespace osu.Game.Overlays return; if (State.Value == Visibility.Visible) - metadataClient.BeginWatchingUserPresence().FireAndForget(); + userPresenceWatchToken ??= metadataClient.BeginWatchingUserPresence(); else - metadataClient.EndWatchingUserPresence().FireAndForget(); + { + userPresenceWatchToken?.Dispose(); + userPresenceWatchToken = null; + } } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index a23c394c9f..0fec1625eb 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -75,7 +75,9 @@ namespace osu.Game.Overlays.Dialog return; bodyText = value; + body.Text = value; + body.TextAnchor = bodyText.ToString().Contains('\n') ? Anchor.TopLeft : Anchor.TopCentre; } } @@ -210,13 +212,12 @@ namespace osu.Game.Overlays.Dialog RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopCentre, - Padding = new MarginPadding { Horizontal = 15 }, + Padding = new MarginPadding { Horizontal = 15, Bottom = 10 }, }, body = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 18)) { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, - TextAnchor = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = 15 }, @@ -301,6 +302,7 @@ namespace osu.Game.Overlays.Dialog { content.ScaleTo(0.7f); ring.ResizeTo(ringMinifiedSize); + icon.ScaleTo(0f); } content @@ -308,6 +310,7 @@ namespace osu.Game.Overlays.Dialog .FadeIn(ENTER_DURATION, Easing.OutQuint); ring.ResizeTo(ringSize, ENTER_DURATION * 1.5f, Easing.OutQuint); + icon.Delay(100).ScaleTo(1, ENTER_DURATION * 1.5f, Easing.OutQuint); } protected override void PopOut() diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index da60951ab6..392b170ad2 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -21,7 +21,7 @@ using Realms; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupBeatmapScreenStrings), nameof(FirstRunSetupBeatmapScreenStrings.Header))] - public partial class ScreenBeatmaps : FirstRunSetupScreen + public partial class ScreenBeatmaps : WizardScreen { private ProgressRoundedButton downloadBundledButton = null!; private ProgressRoundedButton downloadTutorialButton = null!; diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs index 31a56c9748..a583ba5f6b 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -19,7 +20,7 @@ using osu.Game.Overlays.Settings.Sections; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.Behaviour))] - public partial class ScreenBehaviour : FirstRunSetupScreen + public partial class ScreenBehaviour : WizardScreen { private SearchContainer searchContainer; @@ -90,11 +91,13 @@ namespace osu.Game.Overlays.FirstRunSetup new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), - new DebugSection(), }, SearchTerm = SettingsItem.CLASSIC_DEFAULT_SEARCH_TERM, } }; + + if (DebugUtils.IsDebugBuild) + searchContainer.Add(new DebugSection()); } private void applyClassic() diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index 5eb38b6e11..5bdcd8e850 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -31,7 +31,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunOverlayImportFromStableScreenStrings), nameof(FirstRunOverlayImportFromStableScreenStrings.Header))] - public partial class ScreenImportFromStable : FirstRunSetupScreen + public partial class ScreenImportFromStable : WizardScreen { private static readonly Vector2 button_size = new Vector2(400, 50); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index d0eefa55c5..fc64408775 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -32,7 +32,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.UIScaling))] - public partial class ScreenUIScale : FirstRunSetupScreen + public partial class ScreenUIScale : WizardScreen { [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index 68c6c78986..e03a08dd46 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -23,7 +23,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.WelcomeTitle))] - public partial class ScreenWelcome : FirstRunSetupScreen + public partial class ScreenWelcome : WizardScreen { [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) @@ -65,6 +65,8 @@ namespace osu.Game.Overlays.FirstRunSetup }; } + public override LocalisableString? NextStepText => FirstRunSetupOverlayStrings.GetStarted; + private partial class LanguageSelectionFlow : FillFlowContainer { private Bindable language = null!; diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index 1a302cf51d..c2e89f32f1 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -1,38 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Framework.Screens; -using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Overlays.FirstRunSetup; -using osu.Game.Overlays.Mods; using osu.Game.Overlays.Notifications; using osu.Game.Screens; -using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; namespace osu.Game.Overlays { [Cached] - public partial class FirstRunSetupOverlay : ShearedOverlayContainer + public partial class FirstRunSetupOverlay : WizardOverlay { [Resolved] private IPerformFromScreenRunner performer { get; set; } = null!; @@ -43,28 +27,8 @@ namespace osu.Game.Overlays [Resolved] private OsuConfigManager config { get; set; } = null!; - private ScreenStack? stack; - - public ShearedButton? NextButton => DisplayedFooterContent?.NextButton; - private readonly Bindable showFirstRunSetup = new Bindable(); - private int? currentStepIndex; - - /// - /// The currently displayed screen, if any. - /// - public FirstRunSetupScreen? CurrentScreen => (FirstRunSetupScreen?)stack?.CurrentScreen; - - private readonly List steps = new List(); - - private Container screenContent = null!; - - private Container content = null!; - - private LoadingSpinner loading = null!; - private ScheduledDelegate? loadingShowDelegate; - public FirstRunSetupOverlay() : base(OverlayColourScheme.Purple) { @@ -73,67 +37,15 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuColour colours, LegacyImportManager? legacyImportManager) { - steps.Add(typeof(ScreenWelcome)); - steps.Add(typeof(ScreenUIScale)); - steps.Add(typeof(ScreenBeatmaps)); + AddStep(); + AddStep(); + AddStep(); if (legacyImportManager?.SupportsImportFromStable == true) - steps.Add(typeof(ScreenImportFromStable)); - steps.Add(typeof(ScreenBehaviour)); + AddStep(); + AddStep(); Header.Title = FirstRunSetupOverlayStrings.FirstRunSetupTitle; Header.Description = FirstRunSetupOverlayStrings.FirstRunSetupDescription; - - MainAreaContent.AddRange(new Drawable[] - { - content = new PopoverContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = 20 }, - Child = new GridContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(minSize: 640, maxSize: 800), - new Dimension(), - }, - Content = new[] - { - new[] - { - Empty(), - new InputBlockingContainer - { - Masking = true, - CornerRadius = 14, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6, - }, - loading = new LoadingSpinner(), - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Vertical = 20 }, - Child = screenContent = new Container { RelativeSizeAxes = Axes.Both, }, - }, - }, - }, - Empty(), - }, - } - } - }, - }); } protected override void LoadComplete() @@ -145,55 +57,6 @@ namespace osu.Game.Overlays if (showFirstRunSetup.Value) Show(); } - [Resolved] - private ScreenFooter footer { get; set; } = null!; - - public new FirstRunSetupFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as FirstRunSetupFooterContent; - - public override VisibilityContainer CreateFooterContent() - { - var footerContent = new FirstRunSetupFooterContent - { - ShowNextStep = showNextStep, - }; - - footerContent.OnLoadComplete += _ => updateButtons(); - return footerContent; - } - - public override bool OnBackButton() - { - if (currentStepIndex == 0) - return false; - - Debug.Assert(stack != null); - - stack.CurrentScreen.Exit(); - currentStepIndex--; - - updateButtons(); - return true; - } - - public override bool OnPressed(KeyBindingPressEvent e) - { - if (!e.Repeat) - { - switch (e.Action) - { - case GlobalAction.Select: - DisplayedFooterContent?.NextButton.TriggerClick(); - return true; - - case GlobalAction.Back: - footer.BackButton.TriggerClick(); - return false; - } - } - - return base.OnPressed(e); - } - public override void Show() { // if we are valid for display, only do so after reaching the main menu. @@ -207,24 +70,11 @@ namespace osu.Game.Overlays }, new[] { typeof(MainMenu) }); } - protected override void PopIn() - { - base.PopIn(); - - content.ScaleTo(0.99f) - .ScaleTo(1, 400, Easing.OutQuint); - - if (currentStepIndex == null) - showFirstStep(); - } - protected override void PopOut() { base.PopOut(); - content.ScaleTo(0.99f, 400, Easing.OutQuint); - - if (currentStepIndex != null) + if (CurrentStepIndex != null) { notificationOverlay.Post(new SimpleNotification { @@ -237,112 +87,14 @@ namespace osu.Game.Overlays }, }); } - else - { - stack?.FadeOut(100) - .Expire(); - } } - private void showFirstStep() + protected override void ShowNextStep() { - Debug.Assert(currentStepIndex == null); + base.ShowNextStep(); - screenContent.Child = stack = new ScreenStack - { - RelativeSizeAxes = Axes.Both, - }; - - currentStepIndex = -1; - showNextStep(); - } - - private void showNextStep() - { - Debug.Assert(currentStepIndex != null); - Debug.Assert(stack != null); - - currentStepIndex++; - - if (currentStepIndex < steps.Count) - { - var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value])!; - - loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); - nextScreen.OnLoadComplete += _ => - { - loadingShowDelegate?.Cancel(); - loading.Hide(); - }; - - stack.Push(nextScreen); - } - else - { + if (CurrentStepIndex == null) showFirstRunSetup.Value = false; - currentStepIndex = null; - Hide(); - } - - updateButtons(); - } - - private void updateButtons() => DisplayedFooterContent?.UpdateButtons(currentStepIndex, steps); - - public partial class FirstRunSetupFooterContent : VisibilityContainer - { - public ShearedButton NextButton { get; private set; } = null!; - - public Action? ShowNextStep; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.Both; - - InternalChild = NextButton = new ShearedButton(0) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Right = 12f }, - RelativeSizeAxes = Axes.X, - Width = 1, - Text = FirstRunSetupOverlayStrings.GetStarted, - DarkerColour = colourProvider.Colour2, - LighterColour = colourProvider.Colour1, - Action = () => ShowNextStep?.Invoke(), - }; - } - - public void UpdateButtons(int? currentStep, IReadOnlyList steps) - { - NextButton.Enabled.Value = currentStep != null; - - if (currentStep == null) - return; - - bool isFirstStep = currentStep == 0; - bool isLastStep = currentStep == steps.Count - 1; - - if (isFirstStep) - NextButton.Text = FirstRunSetupOverlayStrings.GetStarted; - else - { - NextButton.Text = isLastStep - ? CommonStrings.Finish - : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStep.Value + 1].GetLocalisableDescription()})"); - } - } - - protected override void PopIn() - { - this.FadeIn(); - } - - protected override void PopOut() - { - this.Delay(400).FadeOut(); - } } } } diff --git a/osu.Game/Overlays/KudosuTable.cs b/osu.Game/Overlays/KudosuTable.cs index 93884435a4..d6eaf586b9 100644 --- a/osu.Game/Overlays/KudosuTable.cs +++ b/osu.Game/Overlays/KudosuTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays protected override CountryCode GetCountryCode(APIUser item) => item.CountryCode; - protected override Drawable CreateFlagContent(APIUser item) + protected override Drawable[] CreateFlagContent(APIUser item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -89,7 +89,7 @@ namespace osu.Game.Overlays TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item); - return username; + return [username]; } } } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 13e528ff8f..215a946b42 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; @@ -63,6 +64,7 @@ namespace osu.Game.Overlays.Login }, username = new OsuTextBox { + InputProperties = new TextInputProperties(TextInputType.Username, false), PlaceholderText = UsersStrings.LoginUsername.ToLower(), RelativeSizeAxes = Axes.X, Text = api.ProvidedUsername, @@ -127,7 +129,7 @@ namespace osu.Game.Overlays.Login } }; - forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset"); + forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.Endpoints.WebsiteUrl}/home/password-reset"); password.OnCommit += (_, _) => performLogin(); diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 84bd0c36b9..6d74fc442e 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -9,13 +9,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; using osu.Game.Users; using osuTK; @@ -38,14 +38,15 @@ namespace osu.Game.Overlays.Login /// public Action? RequestHide; - private IBindable user = null!; - private readonly Bindable status = new Bindable(); - private readonly IBindable apiState = new Bindable(); + private readonly Bindable configUserStatus = new Bindable(); [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty; public bool Bounding @@ -68,17 +69,11 @@ namespace osu.Game.Overlays.Login { base.LoadComplete(); + config.BindWith(OsuSetting.UserOnlineStatus, configUserStatus); + configUserStatus.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true); + apiState.BindTo(api.State); apiState.BindValueChanged(onlineStateChanged, true); - - user = api.LocalUser.GetBoundCopy(); - user.BindValueChanged(u => - { - status.UnbindBindings(); - status.BindTo(u.NewValue.Status); - }, true); - - status.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -157,23 +152,23 @@ namespace osu.Game.Overlays.Login }, }; - updateDropdownCurrent(status.Value); + updateDropdownCurrent(configUserStatus.Value); dropdown.Current.BindValueChanged(action => { switch (action.NewValue) { case UserAction.Online: - api.LocalUser.Value.Status.Value = UserStatus.Online; + configUserStatus.Value = UserStatus.Online; dropdown.StatusColour = colours.Green; break; case UserAction.DoNotDisturb: - api.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb; + configUserStatus.Value = UserStatus.DoNotDisturb; dropdown.StatusColour = colours.Red; break; case UserAction.AppearOffline: - api.LocalUser.Value.Status.Value = UserStatus.Offline; + configUserStatus.Value = UserStatus.Offline; dropdown.StatusColour = colours.Gray7; break; diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs index 77835b1f09..74db58e225 100644 --- a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Graphics; @@ -62,6 +63,7 @@ namespace osu.Game.Overlays.Login }, codeTextBox = new OsuTextBox { + InputProperties = new TextInputProperties(TextInputType.Code), PlaceholderText = "Enter code", RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -96,7 +98,7 @@ namespace osu.Game.Overlays.Login 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.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.Endpoints.WebsiteUrl}/home/password-reset"); explainText.AddText(". You can also "); explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () => { @@ -121,9 +123,11 @@ namespace osu.Game.Overlays.Login codeTextBox.Current.BindValueChanged(code => { - if (code.NewValue.Length == 8) + string trimmedCode = code.NewValue.Trim(); + + if (trimmedCode.Length == 8) { - api.AuthenticateSecondFactor(code.NewValue); + api.AuthenticateSecondFactor(trimmedCode); codeTextBox.Current.Disabled = true; } }); diff --git a/osu.Game/Overlays/MedalAnimation.cs b/osu.Game/Overlays/MedalAnimation.cs index daceeedf47..fdca0b2cc7 100644 --- a/osu.Game/Overlays/MedalAnimation.cs +++ b/osu.Game/Overlays/MedalAnimation.cs @@ -245,18 +245,19 @@ namespace osu.Game.Overlays this.FadeOut(200); } - public void Dismiss() + public bool Dismiss() { if (drawableMedal != null && drawableMedal.State != DisplayState.Full) { // if we haven't yet, play out the animation fully drawableMedal.State = DisplayState.Full; FinishTransforms(true); - return; + return false; } Hide(); Expire(); + return true; } private partial class BackgroundStrip : Container diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 19f61cb910..25e22ffbda 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays private IAPIProvider api { get; set; } = null!; private Container medalContainer = null!; - private MedalAnimation? lastAnimation; + private MedalAnimation? currentMedalDisplay; [BackgroundDependencyLoader] private void load() @@ -54,11 +53,12 @@ namespace osu.Game.Overlays { base.LoadComplete(); - OverlayActivationMode.BindValueChanged(val => - { - if (val.NewValue == OverlayActivation.All && (queuedMedals.Any() || medalContainer.Any() || lastAnimation?.IsLoaded == false)) - Show(); - }, true); + OverlayActivationMode.BindValueChanged(_ => showNextMedal(), true); + } + + public override void Hide() + { + // don't allow hiding the overlay via any method other than our own. } private void handleMedalMessages(SocketMessage obj) @@ -83,34 +83,18 @@ namespace osu.Game.Overlays var medalAnimation = new MedalAnimation(medal); - queuedMedals.Enqueue(medalAnimation); Logger.Log($"Queueing medal unlock for \"{medal.Name}\" ({queuedMedals.Count} to display)"); - if (OverlayActivationMode.Value == OverlayActivation.All) - Scheduler.AddOnce(Show); - } - - protected override void Update() - { - base.Update(); - - if (medalContainer.Any() || lastAnimation?.IsLoaded == false) - return; - - if (!queuedMedals.TryDequeue(out lastAnimation)) + Schedule(() => LoadComponentAsync(medalAnimation, m => { - Logger.Log("All queued medals have been displayed!"); - Hide(); - return; - } - - Logger.Log($"Preparing to display \"{lastAnimation.Medal.Name}\""); - LoadComponentAsync(lastAnimation, medalContainer.Add); + queuedMedals.Enqueue(m); + showNextMedal(); + })); } protected override bool OnClick(ClickEvent e) { - lastAnimation?.Dismiss(); + progressDisplayByUser(); return true; } @@ -118,19 +102,54 @@ namespace osu.Game.Overlays { if (e.Action == GlobalAction.Back) { - lastAnimation?.Dismiss(); + progressDisplayByUser(); return true; } return base.OnPressed(e); } + private void progressDisplayByUser() + { + // Dismissing may sometimes play out the medal animation rather than immediately dismissing. + if (currentMedalDisplay?.Dismiss() == false) + return; + + currentMedalDisplay = null; + showNextMedal(); + } + + private void showNextMedal() + { + // If already displayed, keep displaying medals regardless of activation mode changes. + if (OverlayActivationMode.Value != OverlayActivation.All && State.Value == Visibility.Hidden) + return; + + // A medal is already displaying. + if (currentMedalDisplay != null) + return; + + if (queuedMedals.TryDequeue(out currentMedalDisplay)) + { + Logger.Log($"Displaying \"{currentMedalDisplay.Medal.Name}\""); + medalContainer.Add(currentMedalDisplay); + Show(); + } + else if (State.Value == Visibility.Visible) + { + Logger.Log("All queued medals have been displayed, hiding overlay!"); + base.Hide(); + } + } + protected override void Dispose(bool isDisposing) { - base.Dispose(isDisposing); - + // this event subscription fires async loads, which hard-fail if `CompositeDrawable.disposalCancellationSource` is canceled, which happens in the base call. + // therefore, unsubscribe from this event early to reduce the chances of a stray event firing at an inconvenient spot. if (api.IsNotNull()) api.NotificationsClient.MessageReceived -= handleMedalMessages; + + base.Dispose(isDisposing); } } } diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index 2beed6645a..6b7ffbd1db 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -107,12 +107,7 @@ namespace osu.Game.Overlays.MedalSplash }, }; - description.AddText(medal.Description, s => - { - s.Anchor = Anchor.TopCentre; - s.Origin = Anchor.TopCentre; - s.Font = s.Font.With(size: 16); - }); + description.AddText(medal.Description, s => s.Font = s.Font.With(size: 16)); medalContainer.OnLoadComplete += _ => { diff --git a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs index 2670c20d26..dedd1e336e 100644 --- a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs +++ b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs @@ -177,15 +177,18 @@ namespace osu.Game.Overlays.Mods bpmDisplay.Current.Value = FormatUtils.RoundBPM(BeatmapInfo.Value.BPM, rate); BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(BeatmapInfo.Value.Difficulty); + BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(originalDifficulty); foreach (var mod in Mods.Value.OfType()) - mod.ApplyToDifficulty(originalDifficulty); + mod.ApplyToDifficulty(adjustedDifficulty); Ruleset ruleset = GameRuleset.Value.CreateInstance(); - BeatmapDifficulty adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(adjustedDifficulty, rate); TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty); + circleSizeDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.CircleSize, adjustedDifficulty.CircleSize); + drainRateDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.DrainRate, adjustedDifficulty.DrainRate); approachRateDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.ApproachRate, adjustedDifficulty.ApproachRate); overallDifficultyDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty); diff --git a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs index 03a1b3d0dd..e6d73fe092 100644 --- a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs +++ b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs @@ -223,15 +223,28 @@ namespace osu.Game.Overlays.Mods inputManager = GetContainingInputManager()!; } + private double timeUntilCollapse; + + private const double collapse_grace_time = 180; + private const float collapse_grace_position = 40; + protected override void Update() { base.Update(); - if (ExpandedState.Value == ModCustomisationPanelState.Expanded - && !ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position) - && inputManager.DraggedDrawable == null) + if (ExpandedState.Value == ModCustomisationPanelState.Expanded) { - ExpandedState.Value = ModCustomisationPanelState.Collapsed; + bool canCollapse = !DrawRectangle.Inflate(new Vector2(collapse_grace_position)).Contains(ToLocalSpace(inputManager.CurrentState.Mouse.Position)) + && inputManager.DraggedDrawable == null; + + if (canCollapse) + { + if (timeUntilCollapse <= 0) + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + timeUntilCollapse -= Time.Elapsed; + } + else + timeUntilCollapse = collapse_grace_time; } } } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index ed73340eeb..ac589fbebf 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -35,7 +35,7 @@ using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler + public partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler { public const int BUTTON_WIDTH = 200; @@ -96,7 +96,7 @@ namespace osu.Game.Overlays.Mods /// /// Whether the column with available mod presets should be shown. /// - protected virtual bool ShowPresets => false; + public bool ShowPresets { get; init; } protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false); @@ -125,7 +125,7 @@ namespace osu.Game.Overlays.Mods [Resolved] private ScreenFooter? footer { get; set; } - protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) + public ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } @@ -710,13 +710,13 @@ namespace osu.Game.Overlays.Mods // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. - float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); - float rightVisibleBound = leftVisibleBound + DrawWidth; + double leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); + double rightVisibleBound = leftVisibleBound + DrawWidth; // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. - float leftMovementBound = Math.Min(Current, Target); - float rightMovementBound = Math.Max(Current, Target) + DrawWidth; + double leftMovementBound = Math.Min(Current, Target); + double rightMovementBound = Math.Max(Current, Target) + DrawWidth; foreach (var column in Child) { diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 90fdfd0491..01b0472172 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -63,7 +63,6 @@ namespace osu.Game.Overlays.Music { sprite.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); sprite.Colour = colours.Gray9; - sprite.Padding = new MarginPadding { Top = 1 }; }); SelectedSet.BindValueChanged(set => updateSelectionState(set.NewValue)); diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index cb9d940a05..81ac67bd89 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -136,7 +136,7 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); sidebarContainer.Height = DrawHeight; - sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + sidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } private void loadListing(int? year = null) diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index df07b4f138..dd60e303f6 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public Action? ForwardNotificationToPermanentStore { get; set; } + public required Action ForwardNotificationToPermanentStore { get; init; } public int UnreadCount => Notifications.Count(n => !n.WasClosed && !n.Read); @@ -142,8 +142,15 @@ namespace osu.Game.Overlays notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint); notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ => { + if (notification.Transient) + { + notification.IsInToastTray = false; + notification.Close(false); + return; + } + RemoveInternal(notification, false); - ForwardNotificationToPermanentStore?.Invoke(notification); + ForwardNotificationToPermanentStore(notification); notification.FadeIn(300, Easing.OutQuint); }); @@ -167,7 +174,7 @@ namespace osu.Game.Overlays } height = toastFlow.DrawHeight + 120; - alpha = MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * maxNotificationAlpha; + alpha = Math.Clamp(toastFlow.DrawHeight / 41, 0, 1) * maxNotificationAlpha; } toastContentBackground.Height = (float)Interpolation.DampContinuously(toastContentBackground.Height, height, 10, Clock.ElapsedFrameTime); diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index d48524d8b0..ccfd1adb39 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -34,9 +34,15 @@ namespace osu.Game.Overlays.Notifications public abstract LocalisableString Text { get; set; } /// - /// Whether this notification should forcefully display itself. + /// Important notifications display for longer, and announce themselves at an OS level (ie flashing the taskbar). + /// This defaults to true. /// - public virtual bool IsImportant => true; + public bool IsImportant { get; init; } = true; + + /// + /// Transient notifications only show as a toast, and do not linger in notification history. + /// + public bool Transient { get; init; } /// /// Run on user activating the notification. Return true to close. diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 2362cb11f6..0b42188252 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -191,8 +191,6 @@ namespace osu.Game.Overlays.Notifications public override bool DisplayOnTop => false; - public override bool IsImportant => false; - private readonly ProgressBar progressBar; private Color4 colourQueued; private Color4 colourActive; @@ -206,6 +204,8 @@ namespace osu.Game.Overlays.Notifications public ProgressNotification() { + IsImportant = false; + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/OSD/CopyUrlToast.cs b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs similarity index 58% rename from osu.Game/Overlays/OSD/CopyUrlToast.cs rename to osu.Game/Overlays/OSD/CopiedToClipboardToast.cs index 2c5a9179f2..4059a274ad 100644 --- a/osu.Game/Overlays/OSD/CopyUrlToast.cs +++ b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs @@ -5,10 +5,10 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { - public partial class CopyUrlToast : Toast + public partial class CopiedToClipboardToast : Toast { - public CopyUrlToast() - : base(CommonStrings.General, ToastStrings.UrlCopied, "") + public CopiedToClipboardToast() + : base(CommonStrings.General, ToastStrings.CopiedToClipboard, "") { } } diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index 051873b394..cc5a1b9d2d 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -88,7 +88,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); // don't block header by applying padding equal to the visible header height - loadingContainer.Padding = new MarginPadding { Top = Math.Max(0, Header.Height - ScrollFlow.Current) }; + loadingContainer.Padding = new MarginPadding { Top = (float)Math.Max(0, Header.Height - ScrollFlow.Current) }; } } } diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 4328977a8d..66a8686a88 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Overlays public ScrollBackButton Button { get; private set; } - private readonly Bindable lastScrollTarget = new Bindable(); + private readonly Bindable lastScrollTarget = new Bindable(); [BackgroundDependencyLoader] private void load() @@ -63,7 +63,7 @@ namespace osu.Game.Overlays Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden; } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { base.OnUserScroll(value, animated, distanceDecay); @@ -112,7 +112,7 @@ namespace osu.Game.Overlays private readonly Box background; private readonly SpriteIcon spriteIcon; - public Bindable LastScrollTarget = new Bindable(); + public Bindable LastScrollTarget = new Bindable(); protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs index f0ae0b41fc..ec04a130cf 100644 --- a/osu.Game/Overlays/OverlayStreamItem.cs +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Graphics.UserInterface; @@ -22,7 +20,9 @@ namespace osu.Game.Overlays { public abstract partial class OverlayStreamItem : TabItem { - public readonly Bindable SelectedItem = new Bindable(); + public const float PADDING = 5; + + public readonly Bindable SelectedItem = new Bindable(); private bool userHoveringArea; @@ -38,10 +38,12 @@ namespace osu.Game.Overlays } } - private FillFlowContainer text; - private ExpandingBar expandingBar; - - public const float PADDING = 5; + private FillFlowContainer text = null!; + private ExpandingBar expandingBar = null!; + private Sample selectSample = null!; + private OsuSpriteText? mainTextPiece; + private OsuSpriteText? additionalTextPiece; + private OsuSpriteText? infoTextPiece; protected OverlayStreamItem(T value) : base(value) @@ -51,8 +53,6 @@ namespace osu.Game.Overlays Margin = new MarginPadding(PADDING); } - private Sample selectSample; - [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuColour colours, AudioManager audio) { @@ -65,17 +65,17 @@ namespace osu.Game.Overlays Margin = new MarginPadding { Top = 6 }, Children = new[] { - new OsuSpriteText + mainTextPiece = new OsuSpriteText { Text = MainText, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), }, - new OsuSpriteText + additionalTextPiece = new OsuSpriteText { Text = AdditionalText, Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), }, - new OsuSpriteText + infoTextPiece = new OsuSpriteText { Text = InfoText, Font = OsuFont.GetFont(size: 10), @@ -99,11 +99,47 @@ namespace osu.Game.Overlays SelectedItem.BindValueChanged(_ => updateState(), true); } - protected abstract LocalisableString MainText { get; } + private LocalisableString mainText; - protected abstract LocalisableString AdditionalText { get; } + protected LocalisableString MainText + { + get => mainText; + set + { + mainText = value; - protected virtual LocalisableString InfoText => string.Empty; + if (mainTextPiece != null) + mainTextPiece.Text = value; + } + } + + private LocalisableString additionalText; + + protected LocalisableString AdditionalText + { + get => additionalText; + set + { + additionalText = value; + + if (additionalTextPiece != null) + additionalTextPiece.Text = value; + } + } + + private LocalisableString infoText; + + protected LocalisableString InfoText + { + get => infoText; + set + { + infoText = value; + + if (infoTextPiece != null) + infoTextPiece.Text = value; + } + } protected abstract Color4 GetBarColour(OsuColour colours); diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index d5b4d844b2..db93ec7e05 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -93,7 +93,7 @@ namespace osu.Game.Overlays.Profile.Header addSpacer(topLinkContainer); - if (user.IsOnline) + if (user.WasRecentlyOnline) { topLinkContainer.AddText(UsersStrings.ShowLastvisitOnline); addSpacer(topLinkContainer); @@ -124,12 +124,12 @@ namespace osu.Game.Overlays.Profile.Header } topLinkContainer.AddText("Contributed "); - topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.WebsiteRootUrl}/users/{user.Id}/posts", creationParameters: embolden); + topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/users/{user.Id}/posts", creationParameters: embolden); addSpacer(topLinkContainer); topLinkContainer.AddText("Posted "); - topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.WebsiteRootUrl}/comments?user_id={user.Id}", creationParameters: embolden); + topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/comments?user_id={user.Id}", creationParameters: embolden); string websiteWithoutProtocol = user.Website; diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs index 3e86b2268f..a3dce89ad4 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -8,11 +9,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { @@ -23,6 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components public DailyChallengeTooltipData? TooltipContent { get; private set; } private OsuSpriteText dailyPlayCount = null!; + private Container content = null!; + private CircularContainer completionMark = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; [Resolved] private OsuColour colours { get; set; } = null!; @@ -34,58 +43,91 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load() { AutoSizeAxes = Axes.Both; - CornerRadius = 5; - Masking = true; InternalChildren = new Drawable[] { - new Box + content = new Container { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4, - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(5f), AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, + CornerRadius = 6, + BorderThickness = 2, + BorderColour = colourProvider.Background4, + Masking = true, Children = new Drawable[] { - new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + new Box { - AutoSizeAxes = Axes.Both, - // can't use this because osu-web does weird stuff with \\n. - // Text = UsersStrings.ShowDailyChallengeTitle., - Text = "Daily\nChallenge", - Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, }, - new Container + new FillFlowContainer { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - CornerRadius = 5f, - Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(3f), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, Children = new Drawable[] { - new Box + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background6, + AutoSizeAxes = Axes.Both, + // can't use this because osu-web does weird stuff with \\n. + // Text = UsersStrings.ShowDailyChallengeTitle., + Text = "Daily\nChallenge", + Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, }, - dailyPlayCount = new OsuSpriteText + new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - UseFullGlyphHeight = false, - Colour = colourProvider.Content2, - Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + CornerRadius = 3, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + dailyPlayCount = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + UseFullGlyphHeight = false, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + }, + } }, } }, } }, + completionMark = new CircularContainer + { + Alpha = 0, + Size = new Vector2(16), + Anchor = Anchor.TopRight, + Origin = Anchor.Centre, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Lime1, + }, + new SpriteIcon + { + Size = new Vector2(8), + Colour = colourProvider.Background6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Check, + } + } + }, }; } @@ -114,6 +156,29 @@ namespace osu.Game.Overlays.Profile.Header.Components dailyPlayCount.Text = DailyChallengeStatsDisplayStrings.UnitDay(stats.PlayCount.ToLocalisableString("N0")); dailyPlayCount.Colour = colours.ForRankingTier(DailyChallengeStatsTooltip.TierForPlayCount(stats.PlayCount)); + bool playedToday = stats.LastUpdate?.Date == DateTimeOffset.UtcNow.Date; + bool userIsOnOwnProfile = stats.UserID == api.LocalUser.Value.Id; + + if (playedToday && userIsOnOwnProfile) + { + if (completionMark.Alpha > 0.8f) + { + completionMark.ScaleTo(1.2f).ScaleTo(1, 800, Easing.OutElastic); + } + else + { + completionMark.FadeIn(500, Easing.OutExpo); + completionMark.ScaleTo(1.6f).ScaleTo(1, 500, Easing.OutExpo); + } + + content.BorderColour = colours.Lime1; + } + else + { + completionMark.FadeOut(50); + content.BorderColour = colourProvider.Background4; + } + TooltipContent = new DailyChallengeTooltipData(colourProvider, stats); Show(); diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs index c099009ca4..b036b0a305 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Header.Components Texture = textures.Get(banner.Image), }; - Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/tournaments/{banner.TournamentId}"); + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/tournaments/{banner.TournamentId}"); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index af78d62789..b93f996ec2 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; @@ -15,7 +17,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; -using SharpCompress; namespace osu.Game.Overlays.Profile.Header.Components { @@ -200,16 +201,19 @@ namespace osu.Game.Overlays.Profile.Header.Components case FriendStatus.NotMutual: IdleColour = colour.Green.Opacity(0.7f); - HoverColour = IdleColour.Lighten(0.1f); + HoverColour = IdleColour.Value.Lighten(0.1f); break; case FriendStatus.Mutual: IdleColour = colour.Pink.Opacity(0.7f); - HoverColour = IdleColour.Lighten(0.1f); + HoverColour = IdleColour.Value.Lighten(0.1f); break; + + default: + throw new ArgumentOutOfRangeException(); } - EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint)); + EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour.Value : IdleColour.Value, FADE_DURATION, Easing.OutQuint)); } private enum FriendStatus diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index 3d97082230..10bb69f0f5 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -4,12 +4,14 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; @@ -39,7 +41,6 @@ namespace osu.Game.Overlays.Profile.Header.Components AutoSizeAxes = Axes.Y, AutoSizeDuration = 200, AutoSizeEasing = Easing.OutQuint, - Masking = true, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 15), Children = new Drawable[] @@ -162,18 +163,77 @@ namespace osu.Game.Overlays.Profile.Header.Components scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - - var rankHighest = user?.RankHighest; - - detailGlobalRank.ContentTooltipText = rankHighest != null - ? UsersStrings.ShowRankHighest(rankHighest.Rank.ToLocalisableString("\\##,##0"), rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")) - : string.Empty; + detailGlobalRank.ContentTooltipText = getGlobalRankTooltipText(user); detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.ContentTooltipText = getCountryRankTooltipText(user); rankGraph.Statistics.Value = user?.Statistics; } + private static LocalisableString getGlobalRankTooltipText(APIUser? user) + { + var rankHighest = user?.RankHighest; + var variants = user?.Statistics?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + if (variant.GlobalRank != null) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.GlobalRank.ToLocalisableString("\\##,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + } + + if (rankHighest != null) + { + var rankHighestText = UsersStrings.ShowRankHighest( + rankHighest.Rank.ToLocalisableString("\\##,##0"), + rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); + + if (result == null) + result = rankHighestText; + else + result = LocalisableString.Interpolate($"{result}\n{rankHighestText}"); + } + + return result ?? default; + } + + private static LocalisableString getCountryRankTooltipText(APIUser? user) + { + var variants = user?.Statistics?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + if (variant.CountryRank != null) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.CountryRank.ToLocalisableString("\\##,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + } + + return result ?? default; + } + private partial class ScoreRankInfo : CompositeDrawable { private readonly OsuSpriteText rankCount; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs index dce5c84d12..1cd09566fb 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs @@ -85,7 +85,6 @@ namespace osu.Game.Overlays.Profile.Header.Components { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Full, // Prevents the tooltip of having a sudden size reduction and flickering when the text is being faded out. // Also prevents a potential OnHover/HoverLost feedback loop. AlwaysPresent = true, diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index 92e2017659..74abb0af2a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components @@ -87,7 +88,8 @@ namespace osu.Game.Overlays.Profile.Header.Components { background.Colour = colours.Pink; - Action = () => game?.OpenUrlExternally(@"/home/support"); + // Easy to accidentally click so let's always show the open URL popup. + Action = () => game?.OpenUrlExternally(@"/home/support", LinkWarnMode.AlwaysWarn); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 165a576c03..3d9539ce1f 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header private UpdateableFlag userFlag = null!; private OsuHoverContainer userCountryContainer = null!; private OsuSpriteText userCountryText = null!; + private UpdateableTeamFlag teamFlag = null!; + private OsuSpriteText teamText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; private PreviousUsernamesDisplay previousUsernamesDisplay = null!; @@ -154,29 +156,58 @@ namespace osu.Game.Overlays.Profile.Header titleText = new OsuSpriteText { Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), - Margin = new MarginPadding { Bottom = 5 } + Margin = new MarginPadding { Bottom = 3 }, }, new FillFlowContainer { + Margin = new MarginPadding { Top = 3 }, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), Children = new Drawable[] { - userFlag = new UpdateableFlag - { - Size = new Vector2(28, 20), - }, - userCountryContainer = new OsuHoverContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 5 }, - Child = userCountryText = new OsuSpriteText + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), - }, + userFlag = new UpdateableFlag + { + Size = new Vector2(28, 20), + }, + userCountryContainer = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Child = userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + }, + } }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] + { + teamFlag = new UpdateableTeamFlag + { + Size = new Vector2(40, 20), + }, + teamText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + } + } } }, } @@ -213,10 +244,12 @@ namespace osu.Game.Overlays.Profile.Header cover.User = user; avatar.User = user; usernameText.Text = user?.Username ?? string.Empty; - openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}"; + openUserExternally.Link = $@"{api.Endpoints.WebsiteUrl}/users/{user?.Id ?? 0}"; userFlag.CountryCode = user?.CountryCode ?? default; userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default); + teamFlag.Team = user?.Team; + teamText.Text = user?.Team?.Name ?? string.Empty; supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index 8a0003b4ea..05762f29f9 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -223,7 +223,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent private void addBeatmapsetLink() => content.AddLink(activity.Beatmapset.AsNonNull().Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont()); - private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.WebsiteRootUrl}{url}").Argument.AsNonNull(); + private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.Endpoints.WebsiteUrl}{url}").Argument.AsNonNull(); private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular) => OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true); diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index fb3e58d2ac..733aa7ca54 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; - protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Code); + protected override Drawable[] CreateFlagContent(CountryStatistics item) => [new CountryName(item.Code)]; protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index b9f7e443ca..f4ed41800a 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected abstract CountryCode GetCountryCode(TModel item); - protected abstract Drawable CreateFlagContent(TModel item); + protected abstract Drawable[] CreateFlagContent(TModel item); private OsuSpriteText createIndexDrawable(int index) => new RowText { @@ -92,16 +92,13 @@ namespace osu.Game.Overlays.Rankings.Tables { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), + Spacing = new Vector2(5, 0), Margin = new MarginPadding { Bottom = row_spacing }, - Children = new[] - { - new UpdateableFlag(GetCountryCode(item)) - { - Size = new Vector2(28, 20), - }, - CreateFlagContent(item) - } + Children = + [ + new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20) }, + ..CreateFlagContent(item) + ] }; protected class RankingsTableColumn : TableColumn diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 4d25065578..c651108ec3 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -14,6 +14,8 @@ using osu.Game.Users; using osu.Game.Scoring; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Overlays.Rankings.Tables { @@ -61,7 +63,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override CountryCode GetCountryCode(UserStatistics item) => item.User.CountryCode; - protected sealed override Drawable CreateFlagContent(UserStatistics item) + protected sealed override Drawable[] CreateFlagContent(UserStatistics item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -70,7 +72,7 @@ namespace osu.Game.Overlays.Rankings.Tables TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item.User); - return username; + return [new UpdateableTeamFlag(item.User.Team) { Size = new Vector2(40, 20) }, username]; } protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index b84c441057..1d2129413c 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -1,19 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.DebugSettings; namespace osu.Game.Overlays.Settings.Sections { public partial class DebugSection : SettingsSection { - public override LocalisableString Header => DebugSettingsStrings.DebugSectionHeader; + public override LocalisableString Header => @"Debug"; public override Drawable CreateIcon() => new SpriteIcon { @@ -22,12 +20,12 @@ namespace osu.Game.Overlays.Settings.Sections public DebugSection() { - Add(new GeneralSettings()); - - if (DebugUtils.IsDebugBuild) - Add(new BatchImportSettings()); - - Add(new MemorySettings()); + Children = new Drawable[] + { + new GeneralSettings(), + new BatchImportSettings(), + new MemorySettings(), + }; } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index df46e38491..bd6ada4ca7 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -5,43 +5,28 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Localisation; -using osu.Framework.Screens; -using osu.Game.Localisation; -using osu.Game.Screens; -using osu.Game.Screens.Import; -using osu.Game.Screens.Utility; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { public partial class GeneralSettings : SettingsSubsection { - protected override LocalisableString Header => CommonStrings.General; + protected override LocalisableString Header => @"General"; [BackgroundDependencyLoader] - private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig, IPerformFromScreenRunner? performer) + private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig) { Children = new Drawable[] { new SettingsCheckbox { - LabelText = DebugSettingsStrings.ShowLogOverlay, + LabelText = @"Show log overlay", Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) }, new SettingsCheckbox { - LabelText = DebugSettingsStrings.BypassFrontToBackPass, + LabelText = @"Bypass front-to-back render pass", Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) }, - new SettingsButton - { - Text = DebugSettingsStrings.ImportFiles, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) - }, - new SettingsButton - { - Text = DebugSettingsStrings.RunLatencyCertifier, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) - } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index d5de7ae2db..b693822838 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -11,13 +11,12 @@ using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; -using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { public partial class MemorySettings : SettingsSubsection { - protected override LocalisableString Header => DebugSettingsStrings.MemoryHeader; + protected override LocalisableString Header => @"Memory"; [BackgroundDependencyLoader] private void load(GameHost host, RealmAccess realm) @@ -29,27 +28,27 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { new SettingsButton { - Text = DebugSettingsStrings.ClearAllCaches, + Text = @"Clear all caches", Action = host.Collect }, new SettingsButton { - Text = "Compact realm", + Text = @"Compact realm", Action = () => { // Blocking operations implicitly causes a Compact(). - using (realm.BlockAllOperations("compact")) + using (realm.BlockAllOperations(@"compact")) { } } }, blockAction = new SettingsButton { - Text = "Block realm", + Text = @"Block realm", }, unblockAction = new SettingsButton { - Text = "Unblock realm", + Text = @"Unblock realm", }, }; @@ -57,7 +56,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { try { - IDisposable? token = realm.BlockAllOperations("maintenance"); + IDisposable? token = realm.BlockAllOperations(@"maintenance"); blockAction.Enabled.Value = false; @@ -89,7 +88,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings } catch (Exception e) { - Logger.Error(e, "Blocking realm failed"); + Logger.Error(e, @"Blocking realm failed"); } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs index 048351b4cb..830ccec279 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -35,7 +35,8 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay new SettingsCheckbox { LabelText = GameplaySettingsStrings.LightenDuringBreaks, - Current = config.GetBindable(OsuSetting.LightenDuringBreaks) + Current = config.GetBindable(OsuSetting.LightenDuringBreaks), + Keywords = new[] { "dim", "level" } }, new SettingsCheckbox { diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 82cc952e53..261103173e 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -13,6 +12,7 @@ using osu.Framework.Screens; using osu.Framework.Statistics; using osu.Game.Configuration; using osu.Game.Localisation; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; @@ -36,8 +36,11 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved] private Storage storage { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + [BackgroundDependencyLoader] - private void load(OsuConfigManager config, OsuGame? game) + private void load(OsuConfigManager config) { Add(new SettingsEnumDropdown { @@ -50,23 +53,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Add(checkForUpdatesButton = new SettingsButton { Text = GeneralSettingsStrings.CheckUpdate, - Action = () => - { - checkForUpdatesButton.Enabled.Value = false; - Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(task => Schedule(() => - { - if (!task.GetResultSafely()) - { - notifications?.Post(new SimpleNotification - { - Text = GeneralSettingsStrings.RunningLatestRelease(game!.Version), - Icon = FontAwesome.Solid.CheckCircle, - }); - } - - checkForUpdatesButton.Enabled.Value = true; - })); - } + Action = () => checkForUpdates().FireAndForget() }); } @@ -94,6 +81,44 @@ namespace osu.Game.Overlays.Settings.Sections.General } } + private async Task checkForUpdates() + { + if (updateManager == null || game == null) + return; + + checkForUpdatesButton.Enabled.Value = false; + + var checkingNotification = new ProgressNotification + { + Text = GeneralSettingsStrings.CheckingForUpdates, + }; + notifications?.Post(checkingNotification); + + try + { + bool foundUpdate = await updateManager.CheckForUpdateAsync().ConfigureAwait(true); + + if (!foundUpdate) + { + notifications?.Post(new SimpleNotification + { + Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), + Icon = FontAwesome.Solid.CheckCircle, + }); + } + } + catch + { + } + finally + { + // This sequence allows the notification to be immediately dismissed. + checkingNotification.State = ProgressNotificationState.Cancelled; + checkingNotification.Close(false); + checkForUpdatesButton.Enabled.Value = true; + } + } + private void exportLogs() { ProgressNotification notification = new ProgressNotification diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index a93e6c37af..704fa6e907 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"keybindings", @"controls", @"keyboard", @"keys" }); + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"keybindings", @"controls", @"keyboard", @"keys", @"buttons" }); public BindingSettings(KeyBindingPanel keyConfig) { diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 6eb512fa35..3fb4016498 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -57,10 +57,11 @@ namespace osu.Game.Overlays.Settings.Sections.Input LabelText = MouseSettingsStrings.HighPrecisionMouse, TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, Current = relativeMode, - Keywords = new[] { @"raw", @"input", @"relative", @"cursor" } + Keywords = new[] { @"raw", @"input", @"relative", @"cursor", "sensitivity", "speed", "velocity" }, }, new SensitivitySetting { + Keywords = new[] { "speed", "velocity" }, LabelText = MouseSettingsStrings.CursorSensitivity, Current = localSensitivity }, diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 4c9320c2a6..e104bb7e39 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -45,6 +45,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 }; + private readonly BindableNumber pressureThreshold = new BindableNumber { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f }; + [Resolved] private GameHost host { get; set; } @@ -114,10 +116,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription( + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription( RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + : @"https://opentabletdriver.net/Wiki/FAQ/Linux"))); t.AddLinks(formattedSource.Text, formattedSource.Links); } }), @@ -213,6 +215,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input Current = sizeY, CanBeShown = { BindTarget = enabled } }, + new SettingsPercentageSlider + { + TransferValueOnCommit = true, + LabelText = TabletSettingsStrings.TipPressureForClick, + Current = pressureThreshold, + CanBeShown = { BindTarget = enabled } + }, } }, }; @@ -267,6 +276,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input aspectRatioApplication = Schedule(() => forceAspectRatio(aspect.NewValue)); }); + pressureThreshold.BindTo(tabletHandler.PressureThreshold); + tablet.BindTo(tabletHandler.Tablet); tablet.BindValueChanged(val => Schedule(() => { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs new file mode 100644 index 0000000000..47314dcafe --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Localisation; +using osu.Game.Screens; +using osu.Game.Screens.Import; +using osu.Game.Screens.Utility; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class GeneralSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.General; + + private ISystemFileSelector? selector; + + [BackgroundDependencyLoader] + private void load(OsuGameBase game, GameHost host, IPerformFromScreenRunner? performer) + { + if ((selector = host.CreateSystemFileSelector(game.HandledExtensions.ToArray())) != null) + selector.Selected += f => Task.Run(() => game.Import(f.FullName)); + + AddRange(new Drawable[] + { + new SettingsButton + { + Text = DebugSettingsStrings.ImportFiles, + Action = () => + { + if (selector != null) + selector.Present(); + else + performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())); + }, + }, + new SettingsButton + { + Text = DebugSettingsStrings.RunLatencyCertifier, + Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) + } + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index 3bba480aaa..c0363851ef 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -26,7 +26,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance [Resolved(canBeNull: true)] private OsuGame game { get; set; } - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool AllowExternalScreenChange => false; diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index bd90e4c35d..f1b1511df8 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -23,6 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { + new GeneralSettings(), new BeatmapSettings(), new SkinSettings(), new CollectionsSettings(), diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 7bd0829add..608c6ef1b2 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -29,6 +29,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) }, new SettingsCheckbox + { + LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange, + TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, + Current = config.GetBindable(OsuSetting.NotifyOnFriendPresenceChange), + }, + new SettingsCheckbox { LabelText = OnlineSettingsStrings.HideCountryFlags, Current = config.GetBindable(OsuSetting.HideCountryFlags) diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 9b04f208a7..84767c8619 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -9,18 +9,25 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.SkinEditor; using osu.Game.Screens.Select; using osu.Game.Skinning; +using osuTK; using Realms; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Overlays.Settings.Sections { @@ -64,13 +71,26 @@ namespace osu.Game.Overlays.Settings.Sections Current = skins.CurrentSkinInfo, Keywords = new[] { @"skins" }, }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, + Children = new Drawable[] + { + // This is all super-temporary until we move skin settings to their own panel / overlay. + new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 }, + } + }, new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, Action = () => skinEditor?.ToggleVisibility(), }, - new ExportSkinButton(), - new DeleteSkinButton(), }; } @@ -136,6 +156,34 @@ namespace osu.Game.Overlays.Settings.Sections } } + public partial class RenameSkinButton : SettingsButton, IHasPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = CommonStrings.Rename; + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + } + + public Popover GetPopover() + { + return new RenameSkinPopover(); + } + } + public partial class ExportSkinButton : SettingsButton { [Resolved] @@ -146,7 +194,7 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader] private void load() { - Text = SkinSettingsStrings.ExportSkinButton; + Text = CommonStrings.Export; Action = export; } @@ -184,7 +232,7 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader] private void load() { - Text = SkinSettingsStrings.DeleteSkinButton; + Text = WebCommonStrings.ButtonsDelete; Action = delete; } @@ -201,5 +249,63 @@ namespace osu.Game.Overlays.Settings.Sections dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value)); } } + + public partial class RenameSkinPopover : OsuPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private readonly FocusedTextBox textBox; + + public RenameSkinPopover() + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.TopCentre; + + RoundedButton renameButton; + + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + Width = 250, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + textBox = new FocusedTextBox + { + PlaceholderText = @"Skin name", + FontSize = OsuFont.DEFAULT_FONT_SIZE, + RelativeSizeAxes = Axes.X, + SelectAllOnFocus = true, + }, + renameButton = new RoundedButton + { + Height = 40, + RelativeSizeAxes = Axes.X, + MatchingFilter = true, + Text = "Save", + } + } + }; + + renameButton.Action += rename; + textBox.OnCommit += (_, _) => rename(); + } + + protected override void PopIn() + { + textBox.Text = skins.CurrentSkinInfo.Value.Value.Name; + textBox.TakeFocus(); + + base.PopIn(); + } + + private void rename() => skins.CurrentSkinInfo.Value.PerformWrite(skin => + { + skin.Name = textBox.Text; + PopOut(); + }); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 5e42c3035c..c50d56b458 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -36,11 +36,13 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface }, new SettingsCheckbox { + Keywords = new[] { "intro", "welcome" }, LabelText = UserInterfaceStrings.InterfaceVoices, Current = config.GetBindable(OsuSetting.MenuVoice) }, new SettingsCheckbox { + Keywords = new[] { "intro", "welcome" }, LabelText = UserInterfaceStrings.OsuMusicTheme, Current = config.GetBindable(OsuSetting.MenuMusic) }, diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index 49bd17dfde..cb0d738a2c 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -19,12 +19,6 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { Children = new Drawable[] { - new SettingsCheckbox - { - ClassicDefault = true, - LabelText = UserInterfaceStrings.RightMouseScroll, - Current = config.GetBindable(OsuSetting.SongSelectRightMouseScroll), - }, new SettingsCheckbox { LabelText = UserInterfaceStrings.ShowConvertedBeatmaps, diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 3f5d612eb8..196ddca953 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -6,13 +6,12 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public partial class SettingsButton : RoundedButton, IHasTooltip, IConditionalFilterable + public partial class SettingsButton : RoundedButton, IConditionalFilterable { public SettingsButton() { @@ -25,8 +24,6 @@ namespace osu.Game.Overlays.Settings public BindableBool CanBeShown { get; } = new BindableBool(true); IBindable IConditionalFilterable.CanBeShown => CanBeShown; - public LocalisableString TooltipText { get; set; } - public override IEnumerable FilterTerms { get diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index 4e9d4c0d28..307d88e712 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -5,6 +5,8 @@ using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -76,13 +78,16 @@ namespace osu.Game.Overlays.Settings } } - private partial class BuildDisplay : OsuAnimatedButton + private partial class BuildDisplay : OsuAnimatedButton, IHasContextMenu { private readonly string version; [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + public BuildDisplay(string version) { this.version = version; @@ -108,6 +113,11 @@ namespace osu.Game.Overlays.Settings Colour = DebugUtils.IsDebugBuild ? colours.Red : Color4.White, }); } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Copy version", MenuItemType.Standard, () => game?.CopyToClipboard(version)) + }; } } } diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index fbcdb4a968..2548f3c87b 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; namespace osu.Game.Overlays.Settings { @@ -66,7 +67,10 @@ namespace osu.Game.Overlays.Settings private partial class OutlinedNumberBox : OutlinedTextBox { - protected override bool AllowIme => false; + public OutlinedNumberBox() + { + InputProperties = new TextInputProperties(TextInputType.Number, false); + } protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 9076dadf93..a498f2fe1f 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -7,12 +7,14 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; @@ -27,28 +29,41 @@ namespace osu.Game.Overlays public LocalisableString Title => SettingsStrings.HeaderTitle; public LocalisableString Description => SettingsStrings.HeaderDescription; - protected override IEnumerable CreateSections() => new SettingsSection[] + protected override IEnumerable CreateSections() { - // This list should be kept in sync with ScreenBehaviour. - new GeneralSection(), - new SkinSection(), - new InputSection(createSubPanel(new KeyBindingPanel())), - new UserInterfaceSection(), - new GameplaySection(), - new RulesetSection(), - new AudioSection(), - new GraphicsSection(), - new OnlineSection(), - new MaintenanceSection(), - new DebugSection(), - }; + var sections = new List + { + // This list should be kept in sync with ScreenBehaviour. + new GeneralSection(), + new SkinSection(), + new InputSection(createSubPanel(new KeyBindingPanel())), + new UserInterfaceSection(), + new GameplaySection(), + new RulesetSection(), + new AudioSection(), + new GraphicsSection(), + new OnlineSection(), + new MaintenanceSection(), + }; + + if (DebugUtils.IsDebugBuild) + sections.Add(new DebugSection()); + + return sections; + } private readonly List subPanels = new List(); private SettingsSubPanel lastOpenedSubPanel; protected override Drawable CreateHeader() => new SettingsHeader(Title, Description); - protected override Drawable CreateFooter() => new SettingsFooter(); + + protected override Drawable CreateFooter() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsFooter() + }; public SettingsOverlay() : base(false) @@ -60,6 +75,9 @@ namespace osu.Game.Overlays public void ShowAtControl() where T : Drawable { + // if search isn't cleared then the target control won't be visible if it doesn't match the query + SearchTextBox.Current.SetDefault(); + Show(); // wait for load of sections diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index df50e0f339..9b268c573f 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays public SettingsSectionsContainer SectionsContainer { get; private set; } - private SeekLimitedSearchTextBox searchTextBox; + protected SeekLimitedSearchTextBox SearchTextBox { get; private set; } protected override string PopInSampleName => "UI/settings-pop-in"; protected override double PopInOutSampleBalance => -OsuGameBase.SFX_STEREO_STRENGTH; @@ -135,7 +135,7 @@ namespace osu.Game.Overlays }, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Child = searchTextBox = new SettingsSearchTextBox + Child = SearchTextBox = new SettingsSearchTextBox { RelativeSizeAxes = Axes.X, Origin = Anchor.TopCentre, @@ -183,8 +183,8 @@ namespace osu.Game.Overlays Sidebar?.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(1, TRANSITION_LENGTH / 2, Easing.OutQuint); - searchTextBox.TakeFocus(); - searchTextBox.HoldFocus = true; + SearchTextBox.TakeFocus(); + SearchTextBox.HoldFocus = true; } protected virtual float ExpandedPosition => 0; @@ -199,8 +199,8 @@ namespace osu.Game.Overlays Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(0, TRANSITION_LENGTH / 2, Easing.OutQuint); - searchTextBox.HoldFocus = false; - if (searchTextBox.HasFocus) + SearchTextBox.HoldFocus = false; + if (SearchTextBox.HasFocus) GetContainingFocusManager()!.ChangeFocus(null); } @@ -208,7 +208,7 @@ namespace osu.Game.Overlays protected override void OnFocus(FocusEvent e) { - searchTextBox.TakeFocus(); + SearchTextBox.TakeFocus(); base.OnFocus(e); } @@ -234,7 +234,7 @@ namespace osu.Game.Overlays loading.Hide(); - searchTextBox.Current.BindValueChanged(term => SectionsContainer.SearchTerm = term.NewValue, true); + SearchTextBox.Current.BindValueChanged(term => SectionsContainer.SearchTerm = term.NewValue, true); loadSidebarButtons(); }); diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 53849fa53c..b1a0ca0ccd 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -3,13 +3,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Framework.Layout; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -28,8 +28,6 @@ namespace osu.Game.Overlays private const int header_height = 30; private const int corner_radius = 5; - private readonly Cached headerTextVisibilityCache = new Cached(); - protected override Container Content => content; private readonly FillFlowContainer content = new FillFlowContainer @@ -54,6 +52,10 @@ namespace osu.Game.Overlays private IconButton expandButton = null!; + private InputManager inputManager = null!; + + private Drawable? draggedChild; + /// /// Create a new instance. /// @@ -125,6 +127,8 @@ namespace osu.Game.Overlays { base.LoadComplete(); + inputManager = GetContainingInputManager()!; + Expanded.BindValueChanged(_ => updateExpandedState(true)); updateExpandedState(false); @@ -149,30 +153,31 @@ namespace osu.Game.Overlays { base.Update(); - if (!headerTextVisibilityCache.IsValid) + // These toolbox grouped may be contracted to only show icons. + // For now, let's hide the header to avoid text truncation weirdness in such cases. + headerText.Alpha = (float)Interpolation.DampContinuously(headerText.Alpha, headerText.DrawWidth < DrawWidth ? 1 : 0, 40, Time.Elapsed); + + // Dragged child finished its drag operation. + if (draggedChild != null && inputManager.DraggedDrawable != draggedChild) { - // These toolbox grouped may be contracted to only show icons. - // For now, let's hide the header to avoid text truncation weirdness in such cases. - headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); - headerTextVisibilityCache.Validate(); + draggedChild = null; + updateExpandedState(true); } } - protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) - { - if (invalidation.HasFlag(Invalidation.DrawSize)) - headerTextVisibilityCache.Invalidate(); - - return base.OnInvalidate(invalidation, source); - } - private void updateExpandedState(bool animate) { + // before we collapse down, let's double check the user is not dragging a UI control contained within us. + if (inputManager.DraggedDrawable.IsRootedAt(this)) + { + draggedChild = inputManager.DraggedDrawable; + } + // clearing transforms is necessary to avoid a previous height transform // potentially continuing to get processed while content has changed to autosize. content.ClearTransforms(); - if (Expanded.Value || IsHovered) + if (Expanded.Value || IsHovered || draggedChild != null) { content.AutoSizeAxes = Axes.Y; content.AutoSizeDuration = animate ? transition_duration : 0; @@ -184,7 +189,7 @@ namespace osu.Game.Overlays content.ResizeHeightTo(0, animate ? transition_duration : 0, Easing.OutQuint); } - headerContent.FadeColour(Expanded.Value ? Color4.White : OsuColour.Gray(0.5f), 200, Easing.OutQuint); + headerContent.FadeColour(Expanded.Value ? Color4.White : OsuColour.Gray(0.7f), 200, Easing.OutQuint); } private void updateFadeState() diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs index 3f8d9f80d4..df8cb33a71 100644 --- a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs @@ -111,6 +111,16 @@ namespace osu.Game.Overlays.SkinEditor SelectedItems.AddRange(targetComponents.SelectMany(list => list).Except(SelectedItems).ToArray()); } + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + var referenceBlueprint = blueprints.First().blueprint; + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + return SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, movePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + } + /// /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). /// diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index d0ee2ccd71..534abd1ab3 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -35,6 +35,7 @@ using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Skinning; using osu.Framework.Graphics.Cursor; using osu.Game.Input.Bindings; +using osu.Game.Utils; namespace osu.Game.Overlays.SkinEditor { @@ -373,9 +374,10 @@ namespace osu.Game.Overlays.SkinEditor return; } - changeHandler = new SkinEditorChangeHandler(skinComponentsContainer); - changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); - changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + if (skinComponentsContainer.IsLoaded) + bindChangeHandler(skinComponentsContainer); + else + skinComponentsContainer.OnLoadComplete += d => Schedule(() => bindChangeHandler((SkinnableContainer)d)); content.Child = new SkinBlueprintContainer(skinComponentsContainer); @@ -417,10 +419,21 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); placeComponent(component); } + + void bindChangeHandler(SkinnableContainer skinnableContainer) + { + changeHandler = new SkinEditorChangeHandler(skinnableContainer); + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + } } private void skinChanged() { + if (skins.EnsureMutableSkin()) + // Another skin changed event will arrive which will complete the process. + return; + headerText.Clear(); headerText.AddParagraph(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16)); @@ -438,17 +451,24 @@ namespace osu.Game.Overlays.SkinEditor }); changeHandler?.Dispose(); + changeHandler = null; - skins.EnsureMutableSkin(); + // Schedule is required to ensure that all layout in `LoadComplete` methods has been completed + // before storing an undo state. + // + // See https://github.com/ppy/osu/blob/8e6a4559e3ae8c9892866cf9cf8d4e8d1b72afd0/osu.Game/Skinning/SkinReloadableDrawable.cs#L76. + Schedule(() => + { + var targetContainer = getTarget(selectedTarget.Value); - var targetContainer = getTarget(selectedTarget.Value); + if (targetContainer != null) + changeHandler = new SkinEditorChangeHandler(targetContainer); - if (targetContainer != null) - changeHandler = new SkinEditorChangeHandler(targetContainer); - hasBegunMutating = true; + hasBegunMutating = true; - // Reload sidebar components. - selectedTarget.TriggerChange(); + // Reload sidebar components. + selectedTarget.TriggerChange(); + }); } /// @@ -709,7 +729,7 @@ namespace osu.Game.Overlays.SkinEditor Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); - public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; + public IEnumerable HandledExtensions => SupportedExtensions.IMAGE_EXTENSIONS; #endregion diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs index 673ba873c4..b805e50df6 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.SkinEditor return; components = new BindableList { BindTarget = firstTarget.Components }; - components.BindCollectionChanged((_, _) => SaveState()); + components.BindCollectionChanged((_, _) => SaveState(), true); } protected override void WriteCurrentStateToStream(MemoryStream stream) diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index ada2f6ff86..bd1c944847 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -7,8 +7,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osuTK; namespace osu.Game.Overlays.Toolbar { @@ -17,6 +19,8 @@ namespace osu.Game.Overlays.Toolbar private OsuSpriteText realTime; private OsuSpriteText gameTime; + private FillFlowContainer runningText; + private bool showRuntime = true; public bool ShowRuntime @@ -52,17 +56,36 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader] private void load(OsuColour colours) { - AutoSizeAxes = Axes.Y; + AutoSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - realTime = new OsuSpriteText(), - gameTime = new OsuSpriteText + realTime = new OsuSpriteText + { + Font = OsuFont.Default.With(fixedWidth: true), + Spacing = new Vector2(-1.5f, 0), + }, + runningText = new FillFlowContainer { Y = 14, Colour = colours.PinkLight, - Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), - } + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "running", + Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), + }, + gameTime = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 10, fixedWidth: true, weight: FontWeight.SemiBold), + Spacing = new Vector2(-0.5f, 0), + } + } + }, }; updateMetrics(); @@ -71,14 +94,12 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateDisplay(DateTimeOffset now) { realTime.Text = now.ToLocalisableString(use24HourDisplay ? @"HH:mm:ss" : @"h:mm:ss tt"); - gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; + gameTime.Text = $"{new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; } private void updateMetrics() { - Width = showRuntime || !use24HourDisplay ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare). - - gameTime.FadeTo(showRuntime ? 1 : 0); + runningText.FadeTo(showRuntime ? 1 : 0); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 96c0b15c44..787c525566 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -130,7 +130,7 @@ namespace osu.Game.Overlays.Toolbar break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } }); } diff --git a/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs b/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs new file mode 100644 index 0000000000..f1ad88833b --- /dev/null +++ b/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; + +namespace osu.Game.Overlays.Volume +{ + /// + /// Add to a container or screen to make scrolling anywhere in the container cause the global game volume to be adjusted. + /// + /// + /// This is generally expected behaviour in many locations in osu!stable. + /// + public partial class GlobalScrollAdjustsVolume : Container + { + [Resolved] + private VolumeOverlay? volumeOverlay { get; set; } + + public GlobalScrollAdjustsVolume() + { + RelativeSizeAxes = Axes.Both; + } + + protected override bool OnScroll(ScrollEvent e) + { + if (e.ScrollDelta.Y == 0) + return false; + + // forward any unhandled mouse scroll events to the volume control. + return volumeOverlay?.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise) ?? false; + } + } +} diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs deleted file mode 100644 index 2e8d86d4c7..0000000000 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Input.Bindings; - -namespace osu.Game.Overlays.Volume -{ - public partial class VolumeControlReceptor : Container, IScrollBindingHandler, IHandleGlobalKeyboardInput - { - public Func ActionRequested; - public Func ScrollActionRequested; - - public bool OnPressed(KeyBindingPressEvent e) - { - switch (e.Action) - { - case GlobalAction.DecreaseVolume: - case GlobalAction.IncreaseVolume: - return ActionRequested?.Invoke(e.Action) == true; - - case GlobalAction.ToggleMute: - case GlobalAction.NextVolumeMeter: - case GlobalAction.PreviousVolumeMeter: - if (!e.Repeat) - return ActionRequested?.Invoke(e.Action) == true; - - return false; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - - protected override bool OnScroll(ScrollEvent e) - { - if (e.ScrollDelta.Y == 0) - return false; - - // forward any unhandled mouse scroll events to the volume control. - ScrollActionRequested?.Invoke(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); - return true; - } - - public bool OnScroll(KeyBindingScrollEvent e) => - ScrollActionRequested?.Invoke(e.Action, e.ScrollAmount, e.IsPrecise) ?? false; - } -} diff --git a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs index 555dab852e..81bdae5525 100644 --- a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs +++ b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Wiki Padding = new MarginPadding(padding), Child = new WikiPanelMarkdownContainer(isFullWidth) { - CurrentPath = $@"{api.WebsiteRootUrl}/wiki/", + CurrentPath = $@"{api.Endpoints.WebsiteUrl}/wiki/", Text = text, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 14a25a909d..e9099f1deb 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -100,7 +100,7 @@ namespace osu.Game.Overlays if (articlePage != null) { articlePage.SidebarContainer.Height = DrawHeight; - articlePage.SidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + articlePage.SidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } } @@ -167,7 +167,7 @@ namespace osu.Game.Overlays } else { - LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/{path.Value}/", response.Markdown)); + LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/{path.Value}/", response.Markdown)); } } @@ -176,7 +176,7 @@ namespace osu.Game.Overlays wikiData.Value = null; path.Value = "error"; - LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", + LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/", $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH}).")); } diff --git a/osu.Game/Overlays/WizardOverlay.cs b/osu.Game/Overlays/WizardOverlay.cs new file mode 100644 index 0000000000..2a881045fd --- /dev/null +++ b/osu.Game/Overlays/WizardOverlay.cs @@ -0,0 +1,290 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Footer; + +namespace osu.Game.Overlays +{ + public partial class WizardOverlay : ShearedOverlayContainer + { + private ScreenStack? stack; + + public ShearedButton? NextButton => DisplayedFooterContent?.NextButton; + + protected int? CurrentStepIndex { get; private set; } + + /// + /// The currently displayed screen, if any. + /// + public WizardScreen? CurrentScreen => (WizardScreen?)stack?.CurrentScreen; + + private readonly List steps = new List(); + + private Container screenContent = null!; + + private Container content = null!; + + private LoadingSpinner loading = null!; + private ScheduledDelegate? loadingShowDelegate; + + public bool Completed { get; private set; } + + protected WizardOverlay(OverlayColourScheme scheme) + : base(scheme) + { + } + + [BackgroundDependencyLoader] + private void load() + { + MainAreaContent.AddRange(new Drawable[] + { + content = new PopoverContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 20 }, + Child = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(minSize: 640, maxSize: 800), + new Dimension(), + }, + Content = new[] + { + new[] + { + Empty(), + new InputBlockingContainer + { + Masking = true, + CornerRadius = 14, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6, + }, + loading = new LoadingSpinner(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 20 }, + Child = screenContent = new Container { RelativeSizeAxes = Axes.Both, }, + }, + }, + }, + Empty(), + }, + } + } + }, + }); + } + + [Resolved] + private ScreenFooter footer { get; set; } = null!; + + public new WizardFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as WizardFooterContent; + + public override VisibilityContainer CreateFooterContent() + { + var footerContent = new WizardFooterContent + { + ShowNextStep = ShowNextStep, + }; + + footerContent.OnLoadComplete += _ => updateButtons(); + return footerContent; + } + + public override bool OnBackButton() + { + if (CurrentStepIndex == 0) + return false; + + Debug.Assert(stack != null); + + stack.CurrentScreen.Exit(); + CurrentStepIndex--; + + updateButtons(); + return true; + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (!e.Repeat) + { + switch (e.Action) + { + case GlobalAction.Select: + DisplayedFooterContent?.NextButton.TriggerClick(); + return true; + + case GlobalAction.Back: + footer.BackButton.TriggerClick(); + return false; + } + } + + return base.OnPressed(e); + } + + protected override void PopIn() + { + base.PopIn(); + + content.ScaleTo(0.99f) + .ScaleTo(1, 400, Easing.OutQuint); + + if (CurrentStepIndex == null) + showFirstStep(); + } + + protected override void PopOut() + { + base.PopOut(); + + content.ScaleTo(0.99f, 400, Easing.OutQuint); + + if (CurrentStepIndex == null) + { + stack?.FadeOut(100) + .Expire(); + } + } + + protected void AddStep() + where T : WizardScreen + { + steps.Add(typeof(T)); + } + + private void showFirstStep() + { + Debug.Assert(CurrentStepIndex == null); + + screenContent.Child = stack = new ScreenStack + { + RelativeSizeAxes = Axes.Both, + }; + + CurrentStepIndex = -1; + ShowNextStep(); + } + + protected virtual void ShowNextStep() + { + Debug.Assert(CurrentStepIndex != null); + Debug.Assert(stack != null); + + CurrentStepIndex++; + + if (CurrentStepIndex < steps.Count) + { + var nextScreen = (Screen)Activator.CreateInstance(steps[CurrentStepIndex.Value])!; + + loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); + nextScreen.OnLoadComplete += _ => + { + loadingShowDelegate?.Cancel(); + loading.Hide(); + }; + + stack.Push(nextScreen); + } + else + { + CurrentStepIndex = null; + Completed = true; + Hide(); + } + + updateButtons(); + } + + private void updateButtons() => DisplayedFooterContent?.UpdateButtons(CurrentStepIndex, CurrentScreen, steps); + + public partial class WizardFooterContent : VisibilityContainer + { + public ShearedButton NextButton { get; private set; } = null!; + + public Action? ShowNextStep; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + + InternalChild = NextButton = new ShearedButton(0) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 12f }, + RelativeSizeAxes = Axes.X, + Width = 1, + Text = FirstRunSetupOverlayStrings.GetStarted, + DarkerColour = colourProvider.Colour3, + LighterColour = colourProvider.Colour2, + Action = () => ShowNextStep?.Invoke(), + }; + } + + public void UpdateButtons(int? currentStep, WizardScreen? currentScreen, IReadOnlyList steps) + { + NextButton.Enabled.Value = currentStep != null; + + if (currentStep == null) + return; + + bool isLastStep = currentStep == steps.Count - 1; + + if (currentScreen?.NextStepText != null) + NextButton.Text = currentScreen.NextStepText.Value; + else + { + NextButton.Text = isLastStep + ? CommonStrings.Finish + : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStep.Value + 1].GetLocalisableDescription()})"); + } + } + + protected override void PopIn() + { + this.FadeIn(); + } + + protected override void PopOut() + { + this.Delay(400).FadeOut(); + } + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/WizardScreen.cs similarity index 94% rename from osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs rename to osu.Game/Overlays/WizardScreen.cs index 76921718f2..5112efaa61 100644 --- a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs +++ b/osu.Game/Overlays/WizardScreen.cs @@ -7,15 +7,16 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -namespace osu.Game.Overlays.FirstRunSetup +namespace osu.Game.Overlays { - public abstract partial class FirstRunSetupScreen : Screen + public abstract partial class WizardScreen : Screen { private const float offset = 100; @@ -102,5 +103,7 @@ namespace osu.Game.Overlays.FirstRunSetup base.OnSuspending(e); } + + public virtual LocalisableString? NextStepText => null; } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 7b6bc37a61..59511973f7 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -17,19 +17,15 @@ namespace osu.Game.Rulesets.Difficulty { protected const int ATTRIB_ID_AIM = 1; protected const int ATTRIB_ID_SPEED = 3; - protected const int ATTRIB_ID_OVERALL_DIFFICULTY = 5; - protected const int ATTRIB_ID_APPROACH_RATE = 7; protected const int ATTRIB_ID_MAX_COMBO = 9; protected const int ATTRIB_ID_DIFFICULTY = 11; - protected const int ATTRIB_ID_GREAT_HIT_WINDOW = 13; - protected const int ATTRIB_ID_SCORE_MULTIPLIER = 15; protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23; protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; - protected const int ATTRIB_ID_OK_HIT_WINDOW = 27; protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29; + protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 63b27243d0..5c840a8357 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -62,6 +62,11 @@ namespace osu.Game.Rulesets.Difficulty /// A structure describing the difficulty of the beatmap. public DifficultyAttributes Calculate([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) { + using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + if (!cancellationToken.CanBeCanceled) + cancellationToken = timedCancellationSource.Token; + cancellationToken.ThrowIfCancellationRequested(); preProcess(mods, cancellationToken); @@ -98,6 +103,11 @@ namespace osu.Game.Rulesets.Difficulty /// The set of . public List CalculateTimed([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) { + using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + if (!cancellationToken.CanBeCanceled) + cancellationToken = timedCancellationSource.Token; + cancellationToken.ThrowIfCancellationRequested(); preProcess(mods, cancellationToken); @@ -166,15 +176,10 @@ namespace osu.Game.Rulesets.Difficulty /// /// The original list of s. /// The cancellation token. - private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) + private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken) { playableMods = mods.Select(m => m.DeepClone()).ToArray(); - - // Only pass through the cancellation token if it's non-default. - // This allows for the default timeout to be applied for playable beatmap construction. - Beatmap = cancellationToken == default - ? beatmap.GetPlayableBeatmap(ruleset, playableMods) - : beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); var track = new TrackVirtual(10000); playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); @@ -339,8 +344,87 @@ namespace osu.Game.Rulesets.Difficulty public double TotalBreakTime => baseBeatmap.TotalBreakTime; public IEnumerable GetStatistics() => baseBeatmap.GetStatistics(); public double GetMostCommonBeatLength() => baseBeatmap.GetMostCommonBeatLength(); + public int BeatmapVersion => baseBeatmap.BeatmapVersion; public IBeatmap Clone() => new ProgressiveCalculationBeatmap(baseBeatmap.Clone()); + public double AudioLeadIn + { + get => baseBeatmap.AudioLeadIn; + set => baseBeatmap.AudioLeadIn = value; + } + + public float StackLeniency + { + get => baseBeatmap.StackLeniency; + set => baseBeatmap.StackLeniency = value; + } + + public bool SpecialStyle + { + get => baseBeatmap.SpecialStyle; + set => baseBeatmap.SpecialStyle = value; + } + + public bool LetterboxInBreaks + { + get => baseBeatmap.LetterboxInBreaks; + set => baseBeatmap.LetterboxInBreaks = value; + } + + public bool WidescreenStoryboard + { + get => baseBeatmap.WidescreenStoryboard; + set => baseBeatmap.WidescreenStoryboard = value; + } + + public bool EpilepsyWarning + { + get => baseBeatmap.EpilepsyWarning; + set => baseBeatmap.EpilepsyWarning = value; + } + + public bool SamplesMatchPlaybackRate + { + get => baseBeatmap.SamplesMatchPlaybackRate; + set => baseBeatmap.SamplesMatchPlaybackRate = value; + } + + public double DistanceSpacing + { + get => baseBeatmap.DistanceSpacing; + set => baseBeatmap.DistanceSpacing = value; + } + + public int GridSize + { + get => baseBeatmap.GridSize; + set => baseBeatmap.GridSize = value; + } + + public double TimelineZoom + { + get => baseBeatmap.TimelineZoom; + set => baseBeatmap.TimelineZoom = value; + } + + public CountdownType Countdown + { + get => baseBeatmap.Countdown; + set => baseBeatmap.Countdown = value; + } + + public int CountdownOffset + { + get => baseBeatmap.CountdownOffset; + set => baseBeatmap.CountdownOffset = value; + } + + public int[] Bookmarks + { + get => baseBeatmap.Bookmarks; + set => baseBeatmap.Bookmarks = value; + } + #endregion } } diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index b9efcd683d..78df8a139b 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -2,10 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; namespace osu.Game.Rulesets.Difficulty.Utils { - public static class DifficultyCalculationUtils + public static partial class DifficultyCalculationUtils { /// /// Converts BPM value into milliseconds @@ -46,5 +47,60 @@ namespace osu.Game.Rulesets.Difficulty.Utils /// Exponent /// The output of logistic function public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent)); + + /// + /// Returns the p-norm of an n-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics)) + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + /// The p-norm of the vector. + public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + + /// + /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function) + /// + /// Value to calculate the function for + /// The mean (center) of the bell curve + /// The width (spread) of the curve + /// Multiplier to adjust the curve's height + /// The output of the bell curve function of + public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2))); + + /// + /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double Smoothstep(double x, double start, double end) + { + x = Math.Clamp((x - start) / (end - start), 0.0, 1.0); + + return x * x * (3.0 - 2.0 * x); + } + + /// + /// Smootherstep function (https://en.wikipedia.org/wiki/Smoothstep#Variations) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double Smootherstep(double x, double start, double end) + { + x = Math.Clamp((x - start) / (end - start), 0.0, 1.0); + + return x * x * x * (x * (6.0 * x - 15.0) + 10.0); + } + + /// + /// Reverse linear interpolation function (https://en.wikipedia.org/wiki/Linear_interpolation) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double ReverseLerp(double x, double start, double end) + { + return Math.Clamp((x - start) / (end - start), 0.0, 1.0); + } } } diff --git a/osu.Game/Utils/SpecialFunctions.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs similarity index 98% rename from osu.Game/Utils/SpecialFunctions.cs rename to osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs index 0b0f0598bb..4b89cbe7cc 100644 --- a/osu.Game/Utils/SpecialFunctions.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_ErrorFunction.cs @@ -3,7 +3,6 @@ // All code is referenced from the following: // https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs -// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs /* Copyright (c) 2002-2022 Math.NET @@ -14,12 +13,10 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI using System; -namespace osu.Game.Utils +namespace osu.Game.Rulesets.Difficulty.Utils { - public class SpecialFunctions + public partial class DifficultyCalculationUtils { - private const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; - /// /// ************************************** /// COEFFICIENTS FOR METHOD ErfImp * @@ -666,10 +663,7 @@ namespace osu.Game.Utils { // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably // handle null arguments? It doesn't seem to have been done consistently in this class though. - if (coefficients == null) - { - throw new ArgumentNullException(nameof(coefficients)); - } + ArgumentNullException.ThrowIfNull(coefficients); // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. // Without this check, we attempted to peek coefficients at negative indices! diff --git a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs index d6cd4f4caa..ee950248db 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs @@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Edit.Checks string bank = parts[0]; string sampleSet = parts[1]; - return HitSampleInfo.AllBanks.Contains(bank) - && HitSampleInfo.AllAdditions.Append(HitSampleInfo.HIT_NORMAL).Any(sampleSet.StartsWith); + return HitSampleInfo.ALL_BANKS.Contains(bank) + && HitSampleInfo.ALL_ADDITIONS.Append(HitSampleInfo.HIT_NORMAL).Any(sampleSet.StartsWith); } public class IssueTemplateConsequentDelay : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs index 3358e81d5f..97c1519c24 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Edit.Checks ++objectsWithoutHitsounds; } - private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains); + private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.ALL_ADDITIONS.Any(sample.Name.Contains); private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL); public abstract class IssueTemplateLongPeriod : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs index b8cbe63c1e..8a35b84170 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs @@ -3,13 +3,12 @@ using System.IO; using System.Linq; +using osu.Game.Utils; namespace osu.Game.Rulesets.Edit.Checks.Components { public static class AudioCheckUtils { - public static readonly string[] AUDIO_EXTENSIONS = { "mp3", "ogg", "wav" }; - - public static bool HasAudioExtension(string filename) => AUDIO_EXTENSIONS.Any(Path.GetExtension(filename).ToLowerInvariant().EndsWith); + public static bool HasAudioExtension(string filename) => SupportedExtensions.AUDIO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant()); } } diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 5bf15aee8b..4129a6fb2c 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Edit private EditorClock editorClock { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; + protected EditorBeatmap EditorBeatmap { get; private set; } = null!; [Resolved] private IBeatSnapProvider beatSnapProvider { get; set; } = null!; @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Edit } }); - DistanceSpacingMultiplier.Value = editorBeatmap.BeatmapInfo.DistanceSpacing; + DistanceSpacingMultiplier.Value = EditorBeatmap.DistanceSpacing; DistanceSpacingMultiplier.BindValueChanged(multiplier => { distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; @@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Edit if (multiplier.NewValue != multiplier.OldValue) onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); - editorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; + EditorBeatmap.DistanceSpacing = multiplier.NewValue; }, true); DistanceSpacingMultiplier.BindDisabledChanged(disabled => distanceSpacingSlider.Alpha = disabled ? 0 : 1, true); @@ -191,9 +191,14 @@ namespace osu.Game.Rulesets.Edit } } - public IEnumerable CreateTernaryButtons() => new[] + public IEnumerable CreateTernaryButtons() => new[] { - new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }) + new DrawableTernaryButton + { + Current = DistanceSnapToggle, + Description = "Distance Snap", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }, + } }; public void HandleToggleViaKey(KeyboardEvent key) @@ -260,57 +265,38 @@ namespace osu.Game.Rulesets.Edit #region IDistanceSnapProvider - public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) + public virtual float GetBeatSnapDistance(IHasSliderVelocity? withVelocity = null) { - return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * editorBeatmap.Difficulty.SliderMultiplier * 1 + return (float)(100 * (withVelocity?.SliderVelocityMultiplier ?? 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 / beatSnapProvider.BeatDivisor); } - public virtual float DurationToDistance(HitObject referenceObject, double duration) + public virtual float DurationToDistance(double duration, double timingReference, IHasSliderVelocity? withVelocity = null) { - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); - return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject)); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(timingReference); + return (float)(duration / beatLength * GetBeatSnapDistance(withVelocity)); } - public virtual double DistanceToDuration(HitObject referenceObject, float distance) + public virtual double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity? withVelocity = null) { - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); - return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; + double beatLength = beatSnapProvider.GetBeatLengthAtTime(timingReference); + return distance / GetBeatSnapDistance(withVelocity) * beatLength; } - public virtual double FindSnappedDuration(HitObject referenceObject, float distance) - => beatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; - - public virtual float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) + public virtual float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity? withVelocity = null) { - double referenceTime; + double actualDuration = snapReferenceTime + DistanceToDuration(distance, snapReferenceTime, withVelocity); - switch (target) - { - case DistanceSnapTarget.Start: - referenceTime = referenceObject.StartTime; - break; + double snappedTime = beatSnapProvider.SnapTime(actualDuration, snapReferenceTime); - case DistanceSnapTarget.End: - referenceTime = referenceObject.GetEndTime(); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(target), target, $"Unknown {nameof(DistanceSnapTarget)} value"); - } - - double actualDuration = referenceTime + DistanceToDuration(referenceObject, distance); - - double snappedTime = beatSnapProvider.SnapTime(actualDuration, referenceTime); - - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceTime); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(snapReferenceTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. if (snappedTime > actualDuration + 1) snappedTime -= beatLength; - return DurationToDistance(referenceObject, snappedTime - referenceTime); + return DurationToDistance(snappedTime - snapReferenceTime, snapReferenceTime, withVelocity); } #endregion diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index 8af795f880..2a94ae6017 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -55,12 +55,6 @@ namespace osu.Game.Rulesets.Edit } } - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && anyToolboxHovered(screenSpacePos); - - private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.ScreenSpaceDrawQuad.Contains(screenSpacePos); - protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 4b64548f9c..b38b0291e8 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -269,10 +270,9 @@ namespace osu.Game.Rulesets.Edit }; } - TernaryStates = CreateTernaryButtons().ToArray(); - togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); + togglesCollection.AddRange(CreateTernaryButtons().ToArray()); - sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates); SetSelectTool(); @@ -368,20 +368,15 @@ namespace osu.Game.Rulesets.Edit /// protected abstract IReadOnlyList CompositionTools { get; } - /// - /// A collection of states which will be displayed to the user in the toolbox. - /// - public TernaryButton[] TernaryStates { get; private set; } - /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; + protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// - protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); + protected abstract ComposeBlueprintContainer CreateBlueprintContainer(); protected virtual Drawable CreateHitObjectInspector() => new HitObjectInspector(); @@ -435,9 +430,9 @@ namespace osu.Game.Rulesets.Edit } else { - if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) + if (togglesCollection.ChildrenOfType().ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) { - button.Button.Toggle(); + button.Toggle(); return true; } } @@ -571,28 +566,6 @@ namespace osu.Game.Rulesets.Edit /// The most relevant . protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) - { - var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); - double? targetTime = null; - - if (snapType.HasFlag(SnapType.GlobalGrids)) - { - if (playfield is ScrollingPlayfield scrollingPlayfield) - { - targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); - - // apply beat snapping - targetTime = BeatSnapProvider.SnapTime(targetTime.Value); - - // convert back to screen space - screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); - } - } - - return new SnapResult(screenSpacePosition, targetTime, playfield); - } - #endregion } @@ -601,7 +574,7 @@ namespace osu.Game.Rulesets.Edit /// Generally used to access certain methods without requiring a generic type for . /// [Cached] - public abstract partial class HitObjectComposer : CompositeDrawable, IPositionSnapProvider + public abstract partial class HitObjectComposer : CompositeDrawable { public const float TOOLBOX_CONTRACTED_SIZE_LEFT = 60; public const float TOOLBOX_CONTRACTED_SIZE_RIGHT = 120; @@ -644,11 +617,5 @@ namespace osu.Game.Rulesets.Edit /// The time instant to seek to, in milliseconds. /// The ruleset-specific description of objects to select at the given timestamp. public virtual void SelectFromTimestamp(double timestamp, string objectDescription) { } - - #region IPositionSnapProvider - - public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); - - #endregion } } diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs index 4df2a52743..6720540ec2 100644 --- a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; +using osuTK; namespace osu.Game.Rulesets.Edit { @@ -87,14 +88,13 @@ namespace osu.Game.Rulesets.Edit } /// - /// Updates the time and position of this based on the provided snap information. + /// Updates the time and position of this . /// - /// The snap result information. - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double time) { if (PlacementActive == PlacementState.Waiting) { - HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; + HitObject.StartTime = time; if (HitObject is IHasComboInformation comboInformation) comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); @@ -129,6 +129,8 @@ namespace osu.Game.Rulesets.Edit for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) hasRepeats.NodeSamples[i] = HitObject.Samples.Select(o => o.With()).ToList(); } + + return new SnapResult(screenSpacePosition, time); } /// diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index 17fae9e8b2..bb0a0dbd7f 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -4,7 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Edit { @@ -18,57 +18,67 @@ namespace osu.Game.Rulesets.Edit /// A multiplier which changes the ratio of distance travelled per time unit. /// Importantly, this is provided for manual usage, and not multiplied into any of the methods exposed by this interface. /// - /// + /// Bindable DistanceSpacingMultiplier { get; } /// - /// Retrieves the distance between two points within a timing point that are one beat length apart. + /// Returns the spatial distance between objects which are temporally one beat apart. + /// Depends on: + /// + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// Whether the 's slider velocity should be factored into the returned distance. - /// The distance between two points residing in the timing point that are one beat length apart. - float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true); + float GetBeatSnapDistance(IHasSliderVelocity? withVelocity = null); /// - /// Converts a duration to a distance without applying any snapping. + /// Converts a temporal duration into a spatial distance. + /// Does not perform any snapping. + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The duration to convert. - /// A value that represents as a distance in the timing point. - float DurationToDistance(HitObject referenceObject, double duration); + float DurationToDistance(double duration, double timingReference, IHasSliderVelocity? withVelocity = null); /// - /// Converts a distance to a duration without applying any snapping. + /// Converts a spatial distance into a temporal duration. + /// Does not perform any snapping. + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// A value that represents as a duration in the timing point. - double DistanceToDuration(HitObject referenceObject, float distance); + double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity? withVelocity = null); /// - /// Given a distance from the provided hit object, find the valid snapped duration. + /// Snaps a spatial distance to the beat, relative to . + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// A value that represents as a duration snapped to the closest beat of the timing point. - double FindSnappedDuration(HitObject referenceObject, float distance); - - /// - /// Given a distance from the provided hit object, find the valid snapped distance. - /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// Whether the distance measured should be from the start or the end of . - /// - /// A value that represents snapped to the closest beat of the timing point. - /// The distance will always be less than or equal to the provided . - /// - float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target); - } - - public enum DistanceSnapTarget - { - Start, - End, + float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity? withVelocity = null); } } diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs deleted file mode 100644 index 002a0aafe6..0000000000 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osuTK; - -namespace osu.Game.Rulesets.Edit -{ - /// - /// A snap provider which given a proposed position for a hit object, potentially offers a more correct position and time value inferred from the context of the beatmap. - /// - [Cached] - public interface IPositionSnapProvider - { - /// - /// Given a position, find a valid time and position snap. - /// - /// The screen-space position to be snapped. - /// The type of snapping to apply. - /// The time and position post-snapping. - SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); - } -} diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 52b8a5c796..f2d501d1c4 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Edit @@ -75,18 +76,7 @@ namespace osu.Game.Rulesets.Edit PlacementActive = PlacementState.Finished; } - /// - /// Determines which objects to snap to for the snap result in . - /// - public virtual SnapType SnapType => SnapType.All; - - /// - /// Updates the time and position of this based on the provided snap information. - /// - /// The snap result information. - public virtual void UpdateTimeAndPosition(SnapResult result) - { - } + public abstract SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime); public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs index 223b770b48..3671724042 100644 --- a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs @@ -56,7 +56,12 @@ namespace osu.Game.Rulesets.Edit Spacing = new Vector2(0, 5), Children = new[] { - new DrawableTernaryButton(new TernaryButton(showSpeedChanges, "Show speed changes", () => new SpriteIcon { Icon = FontAwesome.Solid.TachometerAlt })) + new DrawableTernaryButton + { + Current = showSpeedChanges, + Description = "Show speed changes", + CreateIcon = () => new SpriteIcon { Icon = FontAwesome.Solid.TachometerAlt }, + } } }, }); @@ -112,6 +117,23 @@ namespace osu.Game.Rulesets.Edit } } + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) + { + var scrollingPlayfield = PlayfieldAtScreenSpacePosition(screenSpacePosition) as ScrollingPlayfield; + if (scrollingPlayfield == null) + return new SnapResult(screenSpacePosition, null); + + double? targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); + + // apply beat snapping + targetTime = BeatSnapProvider.SnapTime(targetTime.Value); + + // convert back to screen space + screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + + return new SnapResult(screenSpacePosition, targetTime, scrollingPlayfield); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs deleted file mode 100644 index cf743f6ace..0000000000 --- a/osu.Game/Rulesets/Edit/SnapType.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Rulesets.Edit -{ - [Flags] - public enum SnapType - { - None = 0, - - /// - /// Snapping to visible nearby objects. - /// - NearbyObjects = 1 << 0, - - /// - /// Grids which are global to the playfield. - /// - GlobalGrids = 1 << 1, - - /// - /// Grids which are relative to other nearby hit objects. - /// - RelativeGrids = 1 << 2, - - AllGrids = RelativeGrids | GlobalGrids, - - All = NearbyObjects | GlobalGrids | RelativeGrids, - } -} diff --git a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs index d04d7636ec..6697a8d848 100644 --- a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs +++ b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs @@ -31,12 +31,7 @@ namespace osu.Game.Rulesets.Mods protected sealed override Drawable CreateControl() => new SliderControl(sliderDisplayCurrent, CreateSlider); - protected virtual RoundedSliderBar CreateSlider(BindableNumber current) => new RoundedSliderBar - { - RelativeSizeAxes = Axes.X, - Current = current, - KeyboardStep = 0.1f, - }; + protected virtual RoundedSliderBar CreateSlider(BindableNumber current) => new RoundedSliderBar(); /// /// Guards against beatmap values displayed on slider bars being transferred to user override. @@ -111,7 +106,21 @@ namespace osu.Game.Rulesets.Mods { InternalChildren = new Drawable[] { - createSlider(currentNumber) + createSlider(currentNumber).With(slider => + { + slider.RelativeSizeAxes = Axes.X; + slider.Current = currentNumber; + slider.KeyboardStep = 0.1f; + // this looks redundant, but isn't because of the various games this component plays + // (`Current` is nullable and represents the underlying setting value, + // `currentNumber` is not nullable and represents what is getting displayed, + // therefore without this, double-clicking the slider would reset `currentNumber` to its bogus default of 0). + slider.ResetToDefault = () => + { + if (!Current.Disabled) + Current.SetDefault(); + }; + }) }; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index 4ca937bf86..83a48599ca 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -173,10 +173,10 @@ namespace osu.Game.Rulesets.Mods }; drawable.OnRevertResult += (_, result) => { - if (!ratesForRewinding.ContainsKey(result.HitObject)) return; + if (!ratesForRewinding.TryGetValue(result.HitObject, out double rate)) return; if (!shouldProcessResult(result)) return; - recentRates.Insert(0, ratesForRewinding[result.HitObject]); + recentRates.Insert(0, rate); ratesForRewinding.Remove(result.HitObject); recentRates.RemoveAt(recentRates.Count - 1); diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 9f980769e2..07e07b25d3 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -104,6 +104,8 @@ namespace osu.Game.Rulesets.Objects /// The cancellation token. public void ApplyDefaults(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + ApplyDefaultsToSelf(controlPointInfo, difficulty); nestedHitObjects.Clear(); @@ -114,6 +116,8 @@ namespace osu.Game.Rulesets.Objects { foreach (HitObject hitObject in nestedHitObjects) { + cancellationToken.ThrowIfCancellationRequested(); + if (hitObject is IHasComboInformation n) { n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs index 28683583ee..091b0a1e6f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -21,13 +21,21 @@ namespace osu.Game.Rulesets.Objects.Legacy public int ComboOffset { get; set; } - public float X => Position.X; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Position.Y); + } - public float Y => Position.Y; + public float Y + { + get => Position.Y; + set => Position = new Vector2(Position.X, value); + } public Vector2 Position { get; set; } - public LegacyHitObjectType LegacyType { get; set; } + public LegacyHitObjectType LegacyType { get; set; } = LegacyHitObjectType.Circle; public override Judgement CreateJudgement() => new IgnoreJudgement(); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index f8bc0ce112..0162c8017b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Objects.Legacy return PathType.CATMULL; case 'B': - if (input.Length > 1 && int.TryParse(input.Substring(1), out int degree) && degree > 0) + if (input.Length > 1 && int.TryParse(input.AsSpan(1), out int degree) && degree > 0) return PathType.BSpline(degree); return PathType.BEZIER; diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs index d74224892b..939e4a495f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy @@ -16,5 +17,10 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Duration { get; set; } public double EndTime => StartTime + Duration; + + public ConvertHold() + { + LegacyType = LegacyHitObjectType.Hold; + } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index fee68f2f11..dbbe142944 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -8,6 +8,7 @@ using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; namespace osu.Game.Rulesets.Objects.Legacy { @@ -56,6 +57,11 @@ namespace osu.Game.Rulesets.Objects.Legacy public bool GenerateTicks { get; set; } = true; + public ConvertSlider() + { + LegacyType = LegacyHitObjectType.Slider; + } + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs index 59551cd37a..c2b4a9e16b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy @@ -16,5 +17,10 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Duration { get; set; } public double EndTime => StartTime + Duration; + + public ConvertSpinner() + { + LegacyType = LegacyHitObjectType.Spinner; + } } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index 9b8375f208..e5e15042ff 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -44,13 +44,15 @@ namespace osu.Game.Rulesets.Objects PathProgress = 0, }; - if (tickDistance != 0) + for (int span = 0; span < spanCount; span++) { - for (int span = 0; span < spanCount; span++) - { - double spanStartTime = startTime + span * spanDuration; - bool reversed = span % 2 == 1; + cancellationToken.ThrowIfCancellationRequested(); + double spanStartTime = startTime + span * spanDuration; + bool reversed = span % 2 == 1; + + if (tickDistance != 0) + { var ticks = generateTicks(span, spanStartTime, spanDuration, reversed, length, tickDistance, minDistanceFromEnd, cancellationToken); if (reversed) @@ -61,18 +63,18 @@ namespace osu.Game.Rulesets.Objects foreach (var e in ticks) yield return e; + } - if (span < spanCount - 1) + if (span < spanCount - 1) + { + yield return new SliderEventDescriptor { - yield return new SliderEventDescriptor - { - Type = SliderEventType.Repeat, - SpanIndex = span, - SpanStartTime = startTime + span * spanDuration, - Time = spanStartTime + spanDuration, - PathProgress = (span + 1) % 2, - }; - } + Type = SliderEventType.Repeat, + SpanIndex = span, + SpanStartTime = startTime + span * spanDuration, + Time = spanStartTime + spanDuration, + PathProgress = (span + 1) % 2, + }; } } diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 5550815370..eb591ec530 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -31,7 +31,10 @@ namespace osu.Game.Rulesets.Objects /// public readonly Bindable ExpectedDistance = new Bindable(); - public bool HasValidLength => Precision.DefinitelyBigger(Distance, 0); + /// + /// Should be used to check whether placement can continue after a user editor operation. + /// + public bool HasValidLengthForPlacement => Precision.DefinitelyBigger(Distance, 0, 1); /// /// The control points of the path. diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index a631274f74..4ce8166421 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Objects /// Snaps the provided 's duration using the . /// public static void SnapTo(this THitObject hitObject, IDistanceSnapProvider? snapProvider) - where THitObject : HitObject, IHasPath + where THitObject : HitObject, IHasPath, IHasSliderVelocity { - hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? hitObject.Path.CalculatedDistance; + hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance((float)hitObject.Path.CalculatedDistance, hitObject.StartTime, hitObject) ?? hitObject.Path.CalculatedDistance; } /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 3aa68197ec..a9fa918ea0 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -50,6 +50,9 @@ namespace osu.Game.Rulesets.Objects.Types /// new bool NewCombo { get; set; } + /// + new int ComboOffset { get; set; } + /// /// Bindable exposure of . /// @@ -84,19 +87,23 @@ namespace osu.Game.Rulesets.Objects.Types /// The previous hitobject, or null if this is the first object in the beatmap. void UpdateComboInformation(IHasComboInformation? lastObj) { - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; if (NewCombo || lastObj == null) { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } + + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs index 8948fe59a9..e9b3cc46eb 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs @@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting position of the HitObject. /// - Vector2 Position { get; } + Vector2 Position { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs index 7e55b21050..18f1f996e3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting X-position of this HitObject. /// - float X { get; } + float X { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs index d2561b10a7..dcaeaf594a 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting Y-position of this HitObject. /// - float Y { get; } + float Y { get; set; } } } diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 269342460f..fed0c3b51b 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -28,11 +28,12 @@ namespace osu.Game.Rulesets.Scoring result ??= new UnstableRateCalculationResult(); // Handle rewinding in the simplest way possible. - if (hitEvents.Count < result.EventCount + 1) + if (hitEvents.Count < result.LastProcessedIndex + 1) result = new UnstableRateCalculationResult(); - for (int i = result.EventCount; i < hitEvents.Count; i++) + for (int i = result.LastProcessedIndex + 1; i < hitEvents.Count; i++) { + result.LastProcessedIndex = i; HitEvent e = hitEvents[i]; if (!AffectsUnstableRate(e)) @@ -84,6 +85,11 @@ namespace osu.Game.Rulesets.Scoring /// public class UnstableRateCalculationResult { + /// + /// The last result index processed. For internal incremental calculation use. + /// + public int LastProcessedIndex = -1; + /// /// Total events processed. For internal incremental calculation use. /// diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index b6cfca58db..46c0371d9f 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Scoring /// /// /// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as - /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time). + /// "too far in the future"). It should also define when a forced miss should be triggered (as a result of no user input in time). /// [Description(@"Miss")] [EnumMember(Value = "miss")] diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index ebd84fd91b..13d4b67132 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -577,6 +577,11 @@ namespace osu.Game.Rulesets.UI /// public virtual bool AllowGameplayOverlays => true; + /// + /// On mobile devices, this specifies whether this ruleset requires the device to be in portrait orientation. + /// + public virtual bool RequiresPortraitOrientation => false; + /// /// Sets a replay to be used, overriding local input. /// diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index c4feb249f4..50111e64a8 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -3,19 +3,15 @@ using System; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; -using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Game.Beatmaps; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; -using osu.Game.Utils; namespace osu.Game.Rulesets.UI { @@ -46,7 +42,7 @@ namespace osu.Game.Rulesets.UI private readonly Bindable waitingOnFrames = new Bindable(); - private readonly double gameplayStartTime; + public double GameplayStartTime { get; } private IGameplayClock? parentGameplayClock; @@ -89,7 +85,7 @@ namespace osu.Game.Rulesets.UI framedClock = new FramedClock(manualClock = new ManualClock()); - this.gameplayStartTime = gameplayStartTime; + GameplayStartTime = gameplayStartTime; } [BackgroundDependencyLoader(true)] @@ -168,13 +164,7 @@ namespace osu.Game.Rulesets.UI if (lastBackwardsSeekLogTime == null || Math.Abs(Clock.CurrentTime - lastBackwardsSeekLogTime.Value) > 1000) { lastBackwardsSeekLogTime = Clock.CurrentTime; - - string loggableContent = $"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"; - - if (parentGameplayClock is GameplayClockContainer gcc) - loggableContent += $"\n{gcc.ChildrenOfType().Single().GetSnapshot()}"; - - Logger.Error(new SentryOnlyDiagnosticsException("backwards seek"), loggableContent); + Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); } state = PlaybackState.NotValid; @@ -267,8 +257,8 @@ namespace osu.Game.Rulesets.UI return; } - if (manualClock.CurrentTime < gameplayStartTime) - manualClock.CurrentTime = proposedTime = Math.Min(gameplayStartTime, proposedTime); + if (manualClock.CurrentTime < GameplayStartTime) + manualClock.CurrentTime = proposedTime = Math.Min(GameplayStartTime, proposedTime); else if (Math.Abs(manualClock.CurrentTime - proposedTime) > sixty_frame_time * 1.2f) { proposedTime = proposedTime > manualClock.CurrentTime diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 5237425075..6abc7355d5 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -39,7 +39,18 @@ namespace osu.Game.Rulesets.UI private IMod mod; private readonly bool showTooltip; - private readonly bool showExtendedInformation; + + private bool showExtendedInformation; + + public bool ShowExtendedInformation + { + get => showExtendedInformation; + set + { + showExtendedInformation = value; + updateExtendedInformation(); + } + } public IMod Mod { diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index 28e25c72e1..1f91e2c5f0 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -27,7 +27,10 @@ namespace osu.Game.Rulesets.UI private InputManager inputManager; - public int RecordFrameRate = 60; + /// + /// The frame rate to record replays at. + /// + public int RecordFrameRate { get; set; } = 60; [Resolved] private SpectatorClient spectatorClient { get; set; } @@ -76,7 +79,7 @@ namespace osu.Game.Rulesets.UI { var last = target.Replay.Frames.LastOrDefault(); - if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate)) + if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate) * Clock.Rate) return; var position = ScreenSpaceToGamefield?.Invoke(inputManager.CurrentState.Mouse.Position) ?? inputManager.CurrentState.Mouse.Position; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 7841e65935..8b0076afa1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -247,7 +247,12 @@ namespace osu.Game.Rulesets.UI.Scrolling // It is required that we set a lifetime end here to ensure that in scenarios like loading a Player instance to a seeked // location in a beatmap doesn't churn every hit object into a DrawableHitObject. Even in a pooled scenario, the overhead // of this can be quite crippling. - entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value; + // + // However, additionally do not attempt to alter lifetime of judged entries. + // This is to prevent freak accidents like objects suddenly becoming alive because of this estimate assigning a later lifetime + // than the object itself decided it should have when it underwent judgement. + if (!entry.Judged) + entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value; } private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 6ad118547b..2eec12ac28 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -96,7 +96,7 @@ namespace osu.Game.Scoring.Legacy scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo; // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. - beatmapOffset = currentBeatmap.BeatmapInfo.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + beatmapOffset = currentBeatmap.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; /* score.HpGraphString = */ sr.ReadString(); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 0f00cce080..b575c02337 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -142,7 +142,7 @@ namespace osu.Game.Scoring.Legacy StringBuilder replayData = new StringBuilder(); // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. - double offset = beatmap?.BeatmapInfo.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + double offset = beatmap?.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; int lastTime = 0; diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 185e2cab99..5f80c2cd96 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -101,18 +101,6 @@ namespace osu.Game.Screens.Backgrounds } } - /// - /// Reloads beatmap's background. - /// - public void RefreshBackground() - { - Schedule(() => - { - cancellationSource?.Cancel(); - LoadComponentAsync(new BeatmapBackground(beatmap), switchBackground, (cancellationSource = new CancellationTokenSource()).Token); - }); - } - private void switchBackground(BeatmapBackground b) { float newDepth = 0; diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs new file mode 100644 index 0000000000..9982357157 --- /dev/null +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Screens.Backgrounds +{ + public partial class EditorBackgroundScreen : BackgroundScreen + { + private readonly WorkingBeatmap beatmap; + private readonly Container dimContainer; + + private CancellationTokenSource? cancellationTokenSource; + private Bindable dimLevel = null!; + private Bindable showStoryboard = null!; + + private BeatmapBackground background = null!; + private Container storyboardContainer = null!; + + private IFrameBasedClock? clockSource; + + public EditorBackgroundScreen(WorkingBeatmap beatmap) + { + this.beatmap = beatmap; + + InternalChild = dimContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + dimContainer.AddRange(createContent()); + background = dimContainer.OfType().Single(); + storyboardContainer = dimContainer.OfType().Single(); + + dimLevel = config.GetBindable(OsuSetting.EditorDim); + showStoryboard = config.GetBindable(OsuSetting.EditorShowStoryboard); + } + + private IEnumerable createContent() => + [ + new BeatmapBackground(beatmap) { RelativeSizeAxes = Axes.Both, }, + // this kooky container nesting is here because the storyboard needs a custom clock + // but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`), + // or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard). + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new DrawableStoryboard(beatmap.Storyboard) + { + Clock = clockSource ?? Clock, + } + } + ]; + + protected override void LoadComplete() + { + base.LoadComplete(); + + dimLevel.BindValueChanged(_ => dimContainer.FadeColour(OsuColour.Gray(1 - dimLevel.Value), 500, Easing.OutQuint), true); + showStoryboard.BindValueChanged(_ => updateState()); + updateState(0); + } + + private void updateState(double duration = 500) + { + storyboardContainer.FadeTo(showStoryboard.Value ? 1 : 0, duration, Easing.OutQuint); + // yes, this causes overdraw, but is also a (crude) fix for bad-looking transitions on screen entry + // caused by the previous background on the background stack poking out from under this one and then instantly fading out + background.FadeColour(beatmap.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint); + } + + public void ChangeClockSource(IFrameBasedClock frameBasedClock) + { + clockSource = frameBasedClock; + if (IsLoaded) + storyboardContainer.Child.Clock = frameBasedClock; + } + + public void RefreshBackground() + { + cancellationTokenSource?.Cancel(); + LoadComponentsAsync(createContent(), loaded => + { + dimContainer.Clear(); + dimContainer.AddRange(loaded); + + background = dimContainer.OfType().Single(); + storyboardContainer = dimContainer.OfType().Single(); + updateState(0); + }, (cancellationTokenSource ??= new CancellationTokenSource()).Token); + } + + public override bool Equals(BackgroundScreen? other) + { + if (other is not EditorBackgroundScreen otherBeatmapBackground) + return false; + + return base.Equals(other) && beatmap == otherBeatmapBackground.beatmap; + } + } +} diff --git a/osu.Game/Screens/Edit/BookmarkController.cs b/osu.Game/Screens/Edit/BookmarkController.cs new file mode 100644 index 0000000000..80e77364e5 --- /dev/null +++ b/osu.Game/Screens/Edit/BookmarkController.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Components.Menus; + +namespace osu.Game.Screens.Edit +{ + public partial class BookmarkController : Component, IKeyBindingHandler + { + /// + /// Bookmarks menu item (with submenu containing options). Should be added to the 's global menu. + /// + public EditorMenuItem Menu { get; private set; } + + [Resolved] + private EditorClock clock { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + private readonly BindableList bookmarks = new BindableList(); + + private readonly EditorMenuItem removeBookmarkMenuItem; + private readonly EditorMenuItem seekToPreviousBookmarkMenuItem; + private readonly EditorMenuItem seekToNextBookmarkMenuItem; + private readonly EditorMenuItem resetBookmarkMenuItem; + + public BookmarkController() + { + Menu = new EditorMenuItem(EditorStrings.Bookmarks) + { + Items = new MenuItem[] + { + new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime) + { + Hotkey = new Hotkey(GlobalAction.EditorAddBookmark), + }, + removeBookmarkMenuItem = new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeClosestBookmark) + { + Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark) + }, + seekToPreviousBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark) + }, + seekToNextBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark) + }, + resetBookmarkMenuItem = new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => dialogOverlay?.Push(new BookmarkResetDialog(editorBeatmap))) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + bookmarks.BindTo(editorBeatmap.Bookmarks); + } + + protected override void Update() + { + base.Update(); + + bool hasAnyBookmark = bookmarks.Count > 0; + bool hasBookmarkCloseEnoughForDeletion = bookmarks.Any(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000); + + removeBookmarkMenuItem.Action.Disabled = !hasBookmarkCloseEnoughForDeletion; + seekToPreviousBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + seekToNextBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + resetBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + } + + private void addBookmarkAtCurrentTime() + { + int bookmark = (int)clock.CurrentTimeAccurate; + int idx = bookmarks.BinarySearch(bookmark); + if (idx < 0) + bookmarks.Insert(~idx, bookmark); + } + + private void removeClosestBookmark() + { + if (removeBookmarkMenuItem.Action.Disabled) + return; + + int closestBookmark = bookmarks.MinBy(b => Math.Abs(b - clock.CurrentTimeAccurate)); + bookmarks.Remove(closestBookmark); + } + + private void seekBookmark(int direction) + { + int? targetBookmark = direction < 1 + ? bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate) + : bookmarks.Cast().FirstOrDefault(b => b > clock.CurrentTimeAccurate); + + if (targetBookmark != null) + clock.SeekSmoothlyTo(targetBookmark.Value); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorSeekToPreviousBookmark: + seekBookmark(-1); + return true; + + case GlobalAction.EditorSeekToNextBookmark: + seekBookmark(1); + return true; + } + + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.EditorAddBookmark: + addBookmarkAtCurrentTime(); + return true; + + case GlobalAction.EditorRemoveClosestBookmark: + removeClosestBookmark(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game/Screens/Edit/BookmarkResetDialog.cs b/osu.Game/Screens/Edit/BookmarkResetDialog.cs new file mode 100644 index 0000000000..48a0202c86 --- /dev/null +++ b/osu.Game/Screens/Edit/BookmarkResetDialog.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public partial class BookmarkResetDialog : DeletionDialog + { + private readonly EditorBeatmap editor; + + public BookmarkResetDialog(EditorBeatmap editorBeatmap) + { + editor = editorBeatmap; + BodyText = "All Bookmarks"; + } + + [BackgroundDependencyLoader] + private void load() + { + DangerousAction = () => editor.Bookmarks.Clear(); + } + } +} + diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs index da71457004..37337bc79f 100644 --- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs +++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,8 +17,6 @@ namespace osu.Game.Screens.Edit.Components protected readonly IBindable Beatmap = new Bindable(); - protected readonly IBindable Track = new Bindable(); - public readonly Drawable Background; private readonly Container content; @@ -45,10 +42,9 @@ namespace osu.Game.Screens.Edit.Components } [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorClock clock) + private void load(IBindable beatmap) { Beatmap.BindTo(beatmap); - Track.BindTo(clock.Track); } } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 47a13dcfba..76b8811b89 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -99,7 +99,7 @@ namespace osu.Game.Screens.Edit.Components.Menus ForegroundColourHover = colourProvider.Content1; BackgroundColourHover = colourProvider.Background1; - AddInternal(hoverClickSounds = new HoverClickSounds()); + AddInternal(hoverClickSounds = new HoverClickSounds(HoverSampleSet.MenuOpen)); } protected override void LoadComplete() diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 9fe6160ab4..01d777cdc6 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -75,7 +76,7 @@ namespace osu.Game.Screens.Edit.Components } }; - Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempoAdjustment), true); + editorClock.AudioAdjustments.AddAdjustment(AdjustableProperty.Tempo, tempoAdjustment); if (editor != null) currentScreenMode.BindTo(editor.Mode); @@ -105,7 +106,8 @@ namespace osu.Game.Screens.Edit.Components protected override void Dispose(bool isDisposing) { - Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempoAdjustment); + if (editorClock.IsNotNull()) + editorClock.AudioAdjustments.RemoveAdjustment(AdjustableProperty.Tempo, tempoAdjustment); base.Dispose(isDisposing); } @@ -148,7 +150,7 @@ namespace osu.Game.Screens.Edit.Components public LocalisableString TooltipText { get; set; } } - private partial class PlaybackTabControl : OsuTabControl + public partial class PlaybackTabControl : OsuTabControl { private static readonly double[] tempo_values = { 0.25, 0.5, 0.75, 1 }; diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index fcbc719f46..326fdbc731 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,8 +19,29 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - public partial class DrawableTernaryButton : OsuButton, IHasTooltip + public partial class DrawableTernaryButton : OsuButton, IHasTooltip, IHasCurrentValue { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public required LocalisableString Description + { + get => Text; + set => Text = value; + } + + public LocalisableString TooltipText { get; set; } + + /// + /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. + /// + public Func? CreateIcon { get; init; } + private Color4 defaultBackgroundColour; private Color4 defaultIconColour; private Color4 selectedBackgroundColour; @@ -25,14 +49,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons protected Drawable Icon { get; private set; } = null!; - public readonly TernaryButton Button; - - public DrawableTernaryButton(TernaryButton button) + public DrawableTernaryButton() { - Button = button; - - Text = button.Description; - RelativeSizeAxes = Axes.X; } @@ -45,7 +63,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons defaultIconColour = defaultBackgroundColour.Darken(0.5f); selectedIconColour = selectedBackgroundColour.Lighten(0.5f); - Add(Icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(Icon = (CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -59,18 +77,32 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { base.LoadComplete(); - Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); - Button.Enabled.BindTo(Enabled); + current.BindValueChanged(_ => updateSelectionState(), true); Action = onAction; } private void onAction() { - if (!Button.Enabled.Value) + if (!Enabled.Value) return; - Button.Toggle(); + Toggle(); + } + + public void Toggle() + { + switch (Current.Value) + { + case TernaryState.False: + case TernaryState.Indeterminate: + Current.Value = TernaryState.True; + break; + + case TernaryState.True: + Current.Value = TernaryState.False; + break; + } } private void updateSelectionState() @@ -78,7 +110,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons if (!IsLoaded) return; - switch (Button.Bindable.Value) + switch (Current.Value) { case TernaryState.Indeterminate: Icon.Colour = selectedIconColour.Darken(0.5f); @@ -104,7 +136,5 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons Anchor = Anchor.CentreLeft, X = 40f }; - - public LocalisableString TooltipText => Button.Tooltip; } } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs new file mode 100644 index 0000000000..c6ecee5f45 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -0,0 +1,278 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class NewComboTernaryButton : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private readonly BindableList selectedHitObjects = new BindableList(); + private readonly BindableList comboColours = new BindableList(); + + private Container mainButtonContainer = null!; + private ColourPickerButton pickerButton = null!; + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + mainButtonContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new DrawableTernaryButton + { + Current = Current, + Description = "New combo", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }, + }, + }, + pickerButton = new ColourPickerButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Alpha = 0, + Width = 25, + ComboColours = { BindTarget = comboColours } + } + }; + + selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); + if (editorBeatmap.BeatmapSkin != null) + comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedHitObjects.BindCollectionChanged((_, _) => updateState()); + comboColours.BindCollectionChanged((_, _) => updateState()); + Current.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1) + { + mainButtonContainer.Padding = new MarginPadding { Right = 30 }; + pickerButton.SelectedHitObject.Value = hasCombo; + pickerButton.Alpha = 1; + } + else + { + mainButtonContainer.Padding = new MarginPadding(); + pickerButton.Alpha = 0; + } + } + + private partial class ColourPickerButton : OsuButton, IHasPopover + { + public BindableList ComboColours { get; } = new BindableList(); + public Bindable SelectedHitObject { get; } = new Bindable(); + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private SpriteIcon icon = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(icon = new SpriteIcon + { + Icon = FontAwesome.Solid.Palette, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ComboColours.BindCollectionChanged((_, _) => updateState()); + SelectedHitObject.BindValueChanged(val => + { + if (val.OldValue != null) + val.OldValue.ComboIndexWithOffsetsBindable.ValueChanged -= onComboIndexChanged; + + updateState(); + + if (val.NewValue != null) + val.NewValue.ComboIndexWithOffsetsBindable.ValueChanged += onComboIndexChanged; + }, true); + } + + private void onComboIndexChanged(ValueChangedEvent _) => updateState(); + + private void updateState() + { + Enabled.Value = SelectedHitObject.Value != null; + + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1) + { + BackgroundColour = colourProvider.Background3; + icon.Colour = BackgroundColour.Darken(0.5f); + icon.Blending = BlendingParameters.Additive; + } + else + { + BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; + icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); + icon.Blending = BlendingParameters.Inherit; + } + } + + public Popover GetPopover() => new ComboColourPalettePopover(ComboColours, SelectedHitObject.Value.AsNonNull(), editorBeatmap); + } + + private partial class ComboColourPalettePopover : OsuPopover + { + private readonly IReadOnlyList comboColours; + private readonly IHasComboInformation hasComboInformation; + private readonly EditorBeatmap editorBeatmap; + + public ComboColourPalettePopover(IReadOnlyList comboColours, IHasComboInformation hasComboInformation, EditorBeatmap editorBeatmap) + { + this.comboColours = comboColours; + this.hasComboInformation = hasComboInformation; + this.editorBeatmap = editorBeatmap; + + AllowableAnchors = [Anchor.CentreRight]; + } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(comboColours.Count > 0); + var hitObject = hasComboInformation as HitObject; + Debug.Assert(hitObject != null); + + FillFlowContainer container; + + Child = container = new FillFlowContainer + { + Width = 230, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + }; + + int selectedColourIndex = comboIndexFor(hasComboInformation, comboColours); + + for (int i = 0; i < comboColours.Count; i++) + { + int index = i; + + if (getPreviousHitObjectWithCombo(editorBeatmap, hitObject) is IHasComboInformation previousHasCombo + && index == comboIndexFor(previousHasCombo, comboColours) + && !canReuseLastComboColour(editorBeatmap, hitObject)) + { + continue; + } + + container.Add(new OsuClickableContainer + { + Size = new Vector2(50), + Masking = true, + CornerRadius = 25, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = comboColours[index], + }, + selectedColourIndex == index + ? new SpriteIcon + { + Icon = FontAwesome.Solid.Check, + Size = new Vector2(24), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.ForegroundTextColourFor(comboColours[index]), + } + : Empty() + }, + Action = () => + { + int comboDifference = index - selectedColourIndex; + if (comboDifference == 0) + return; + + int newOffset = hasComboInformation.ComboOffset + comboDifference; + // `newOffset` must be positive to serialise correctly - this implements the true math "modulus" rather than the built-in "remainder" % op + // which can return negative results when the first operand is negative + newOffset -= (int)Math.Floor((double)newOffset / comboColours.Count) * comboColours.Count; + + hasComboInformation.ComboOffset = newOffset; + editorBeatmap.BeginChange(); + editorBeatmap.Update((HitObject)hasComboInformation); + editorBeatmap.EndChange(); + this.HidePopover(); + } + }); + } + } + + private static IHasComboInformation? getPreviousHitObjectWithCombo(EditorBeatmap editorBeatmap, HitObject hitObject) + => editorBeatmap.HitObjects.TakeWhile(ho => ho != hitObject).LastOrDefault() as IHasComboInformation; + + private static bool canReuseLastComboColour(EditorBeatmap editorBeatmap, HitObject hitObject) + { + double? closestBreakEnd = editorBeatmap.Breaks.Select(b => b.EndTime) + .Where(t => t <= hitObject.StartTime) + .OrderBy(t => t) + .LastOrDefault(); + + if (closestBreakEnd == null) + return false; + + return editorBeatmap.HitObjects.FirstOrDefault(ho => ho.StartTime >= closestBreakEnd) == hitObject; + } + } + + // compare `EditorBeatmapSkin.updateColours()` et al. for reasoning behind the off-by-one index rotation + private static int comboIndexFor(IHasComboInformation hasComboInformation, IReadOnlyCollection comboColours) + => (hasComboInformation.ComboIndexWithOffsets + comboColours.Count - 1) % comboColours.Count; + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs index 33eb2ac0b4..a9aa4b4227 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs @@ -1,23 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using Humanizer; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; namespace osu.Game.Screens.Edit.Components.TernaryButtons { public partial class SampleBankTernaryButton : CompositeDrawable { - public readonly TernaryButton NormalButton; - public readonly TernaryButton AdditionsButton; + public string BankName { get; } + public Func? CreateIcon { get; init; } - public SampleBankTernaryButton(TernaryButton normalButton, TernaryButton additionsButton) + public readonly BindableWithCurrent NormalState = new BindableWithCurrent(); + public readonly BindableWithCurrent AdditionsState = new BindableWithCurrent(); + + public DrawableTernaryButton NormalButton { get; private set; } = null!; + public DrawableTernaryButton AdditionsButton { get; private set; } = null!; + + public SampleBankTernaryButton(string bankName) { - NormalButton = normalButton; - AdditionsButton = additionsButton; + BankName = bankName; } [BackgroundDependencyLoader] @@ -36,7 +45,12 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons AutoSizeAxes = Axes.Y, Width = 0.5f, Padding = new MarginPadding { Right = 1 }, - Child = new InlineDrawableTernaryButton(NormalButton), + Child = NormalButton = new InlineDrawableTernaryButton + { + Current = NormalState, + Description = BankName.Titleize(), + CreateIcon = CreateIcon, + }, }, new Container { @@ -46,18 +60,18 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons AutoSizeAxes = Axes.Y, Width = 0.5f, Padding = new MarginPadding { Left = 1 }, - Child = new InlineDrawableTernaryButton(AdditionsButton), + Child = AdditionsButton = new InlineDrawableTernaryButton + { + Current = AdditionsState, + Description = BankName.Titleize(), + CreateIcon = CreateIcon, + }, }, }; } private partial class InlineDrawableTernaryButton : DrawableTernaryButton { - public InlineDrawableTernaryButton(TernaryButton button) - : base(button) - { - } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs deleted file mode 100644 index b7aaf517f5..0000000000 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Edit.Components.TernaryButtons -{ - public class TernaryButton - { - public readonly Bindable Bindable; - - public readonly Bindable Enabled = new Bindable(true); - - public readonly string Description; - - /// - /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. - /// - public readonly Func? CreateIcon; - - public string Tooltip { get; set; } = string.Empty; - - public TernaryButton(Bindable bindable, string description, Func? createIcon = null) - { - Bindable = bindable; - Description = description; - CreateIcon = createIcon; - } - - public void Toggle() - { - switch (Bindable.Value) - { - case TernaryState.False: - case TernaryState.Indeterminate: - Bindable.Value = TernaryState.True; - break; - - case TernaryState.True: - Bindable.Value = TernaryState.False; - break; - } - } - } -} diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index 8f2a3d49ca..d17f9011f4 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -18,6 +18,7 @@ namespace osu.Game.Screens.Edit.Components public partial class TimeInfoContainer : BottomBarContainer { private OsuSpriteText bpm = null!; + private OsuSpriteText progress = null!; [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; @@ -36,26 +37,44 @@ namespace osu.Game.Screens.Edit.Components bpm = new OsuSpriteText { Colour = colours.Orange1, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-1, 0), + Position = new Vector2(0, 4), + Anchor = Anchor.CentreRight, + Origin = Anchor.TopRight, + }, + progress = new OsuSpriteText + { + Colour = colours.Purple1, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-1, 0), Anchor = Anchor.CentreLeft, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), Position = new Vector2(2, 4), } }; } private double? lastBPM; + private double? lastProgress; protected override void Update() { base.Update(); double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM; + double newProgress = (int)(editorClock.CurrentTime / editorClock.TrackLength * 100); if (lastBPM != newBPM) { lastBPM = newBPM; bpm.Text = @$"{newBPM:0} BPM"; } + + if (lastProgress != newProgress) + { + lastProgress = newProgress; + progress.Text = @$"{newProgress:0}%"; + } } private partial class TimestampControl : OsuClickableContainer diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs index 189cb4ba4a..4b178dd831 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs @@ -2,7 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Extensions; using osu.Game.Graphics; @@ -15,24 +19,69 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public partial class BookmarkPart : TimelinePart { + private readonly BindableList bookmarks = new BindableList(); + + private DrawablePool pool = null!; + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(pool = new DrawablePool(10)); + } + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); - foreach (int bookmark in beatmap.BeatmapInfo.Bookmarks) - Add(new BookmarkVisualisation(bookmark)); + + bookmarks.UnbindAll(); + bookmarks.BindTo(beatmap.Bookmarks); } - private partial class BookmarkVisualisation : PointVisualisation, IHasTooltip + protected override void LoadComplete() { - public BookmarkVisualisation(double startTime) - : base(startTime) + base.LoadComplete(); + bookmarks.BindCollectionChanged((_, _) => { + Clear(disposeChildren: false); + foreach (int bookmark in bookmarks) + Add(pool.Get(v => v.StartTime = bookmark)); + }, true); + } + + private partial class BookmarkVisualisation : PoolableDrawable, IHasTooltip + { + private int startTime; + + public int StartTime + { + get => startTime; + set + { + if (startTime == value) + return; + + startTime = value; + X = startTime; + } } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.Blue; + private void load(OsuColour colours) + { + RelativePositionAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; - public LocalisableString TooltipText => $"{StartTime.ToEditorFormattedString()} bookmark"; + Anchor = Anchor.CentreLeft; + Origin = Anchor.Centre; + + Width = PointVisualisation.MAX_WIDTH; + Height = 0.4f; + + Colour = colours.Blue; + InternalChild = new FastCircle { RelativeSizeAxes = Axes.Both }; + } + + public LocalisableString TooltipText => $"{((double)StartTime).ToEditorFormattedString()} bookmark"; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs index 67bb1ef500..72b58bcb5f 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Extensions; @@ -36,6 +37,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts : base(time) { Alpha = 0.8f; + + // Display as a small circle on the middle line as to not clash with other displays. + RelativeSizeAxes = Axes.None; + Height = Width = 5; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs index ee7e759ebc..bec9e275cb 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -3,8 +3,8 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -26,7 +26,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } = null!; - protected readonly IBindable Track = new Bindable(); + [Resolved] + private EditorClock editorClock { get; set; } = null!; private readonly Container content; @@ -35,22 +36,17 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts public TimelinePart(Container? content = null) { AddInternal(this.content = content ?? new Container { RelativeSizeAxes = Axes.Both }); - - beatmap.ValueChanged += _ => - { - updateRelativeChildSize(); - }; - - Track.ValueChanged += _ => updateRelativeChildSize(); } [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorClock clock) + private void load(IBindable beatmap) { this.beatmap.BindTo(beatmap); LoadBeatmap(EditorBeatmap); - Track.BindTo(clock.Track); + this.beatmap.ValueChanged += _ => updateRelativeChildSize(); + editorClock.TrackChanged += updateRelativeChildSize; + updateRelativeChildSize(); } private void updateRelativeChildSize() @@ -68,5 +64,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { content.Clear(); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateRelativeChildSize; + } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index c01481e840..568137cce1 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -52,13 +52,6 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }, } }, - new PreviewTimePart - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.4f, - }, new BreakPart { Anchor = Anchor.Centre, @@ -85,6 +78,12 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary RelativeSizeAxes = Axes.Both, Height = 0.4f }, + new PreviewTimePart + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, new MarkerPart { RelativeSizeAxes = Axes.Both }, }; } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs index 169e72fe3f..f5c0ed2382 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -14,6 +15,9 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary { public partial class TestGameplayButton : OsuButton { + [Resolved] + private OsuColour colours { get; set; } = null!; + protected override SpriteText CreateText() => new OsuSpriteText { Depth = -1, @@ -24,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }; [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider) { BackgroundColour = colours.Orange1; SpriteText.Colour = colourProvider.Background6; @@ -33,5 +37,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Text = EditorStrings.TestBeatmap; } + + protected override bool OnMouseDown(MouseDownEvent e) + { + Background.FadeColour(colours.Orange0, 500, Easing.OutQuint); + // don't call base in order to block scale animation + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + Background.FadeColour(colours.Orange1, 300, Easing.OutQuint); + // don't call base in order to block scale animation + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 43a2abe4c4..22df917992 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -146,22 +146,11 @@ namespace osu.Game.Screens.Edit.Compose.Components } } }, - new Drawable[] - { - new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Padding = new MarginPadding { Horizontal = 15, Vertical = 2 }, - Text = "beat snap", - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre, - }, - }, }, RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 40), new Dimension(GridSizeMode.Absolute, 20), - new Dimension(GridSizeMode.Absolute, 15) } } }; @@ -398,6 +387,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly BindableBeatDivisor beatDivisor; + public override bool AcceptsFocus => false; + public TickSliderBar(BindableBeatDivisor beatDivisor) { CurrentNumber.BindTo(this.beatDivisor = beatDivisor); diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs index 766d5b5601..f1b7951999 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs @@ -185,9 +185,28 @@ namespace osu.Game.Screens.Edit.Compose.Components private void onDirectionChanged(ValueChangedEvent direction) { - Origin = Anchor = direction.NewValue == ScrollingDirection.Up - ? Anchor.TopLeft - : Anchor.BottomLeft; + switch (direction.NewValue) + { + case ScrollingDirection.Up: + Anchor = Anchor.TopLeft; + Origin = Anchor.CentreLeft; + break; + + case ScrollingDirection.Down: + Anchor = Anchor.BottomLeft; + Origin = Anchor.CentreLeft; + break; + + case ScrollingDirection.Left: + Anchor = Anchor.TopLeft; + Origin = Anchor.TopCentre; + break; + + case ScrollingDirection.Right: + Anchor = Anchor.TopRight; + Origin = Anchor.TopCentre; + break; + } bool isHorizontal = direction.NewValue == ScrollingDirection.Left || direction.NewValue == ScrollingDirection.Right; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index e12574f7ee..b49dee279e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -43,9 +43,6 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly Dictionary> blueprintMap = new Dictionary>(); - [Resolved(canBeNull: true)] - private IPositionSnapProvider snapProvider { get; set; } - [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } @@ -118,19 +115,19 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnMouseDown(MouseDownEvent e) { - bool selectionPerformed = performMouseDownActions(e); + bool handled = performMouseDownActions(e); bool movementPossible = prepareSelectionMovement(e); - // check if selection has occurred - if (selectionPerformed) + if (SelectedItems.Any()) { - // only unmodified right click should show context menu + // if there is a selection and there are no modifiers pressed, don't block so the context menu still shows. bool shouldShowContextMenu = e.Button == MouseButton.Right && !e.ShiftPressed && !e.AltPressed && !e.SuperPressed; - - // stop propagation if not showing context menu return !shouldShowContextMenu; } + if (handled) + return true; + // even if a selection didn't occur, a drag event may still move the selection. return e.Button == MouseButton.Left && movementPossible; } @@ -333,19 +330,19 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void RemoveBlueprintFor(T item) { - if (!blueprintMap.Remove(item, out var blueprint)) + if (!blueprintMap.Remove(item, out var blueprintToRemove)) return; - blueprint.Deselect(); - blueprint.Selected -= OnBlueprintSelected; - blueprint.Deselected -= OnBlueprintDeselected; + blueprintToRemove.Deselect(); + blueprintToRemove.Selected -= OnBlueprintSelected; + blueprintToRemove.Deselected -= OnBlueprintDeselected; - SelectionBlueprints.Remove(blueprint, true); + SelectionBlueprints.Remove(blueprintToRemove, true); - if (movementBlueprints?.Contains(blueprint) == true) + if (movementBlueprints?.Any(m => m.blueprint == blueprintToRemove) == true) finishSelectionMovement(); - OnBlueprintRemoved(blueprint.Item); + OnBlueprintRemoved(blueprintToRemove.Item); } /// @@ -433,7 +430,10 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Finishes the current blueprint selection. /// /// The mouse event which triggered end of selection. - /// Whether a click selection was active. + /// + /// Whether the mouse event is considered to be fully handled. + /// If the return value is , the standard click / mouse up action will follow. + /// private bool endClickSelection(MouseButtonEvent e) { // If already handled a selection, double-click, or drag, we don't want to perform a mouse up / click action. @@ -443,14 +443,16 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.ControlPressed) { - // if a selection didn't occur, we may want to trigger a deselection. - // Iterate from the top of the input stack (blueprints closest to the front of the screen first). // Priority is given to already-selected blueprints. foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Where(b => b.IsHovered).OrderByDescending(b => b.IsSelected)) return clickSelectionHandled = SelectionHandler.MouseUpSelectionRequested(blueprint, e); - return false; + // can only be reached if there are no hovered blueprints. + // in that case, we still want to suppress mouse up / click handling, because when control is pressed, + // it is presumed we want to add to existing selection, not remove from it + // (unless explicitly control-clicking a selected object, which is handled above). + return true; } if (selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) @@ -533,8 +535,7 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement - private Vector2[][] movementBlueprintsOriginalPositions; - private SelectionBlueprint[] movementBlueprints; + private (SelectionBlueprint blueprint, Vector2[] originalSnapPositions)[] movementBlueprints; /// /// Whether a blueprint is currently being dragged. @@ -567,8 +568,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item - movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); - movementBlueprintsOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSnapPoints).ToArray(); + movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).Select(b => (b, b.ScreenSpaceSnapPoints)).ToArray(); return true; } @@ -589,68 +589,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - Debug.Assert(movementBlueprintsOriginalPositions != null); - - Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - - if (snapProvider != null) - { - for (int i = 0; i < movementBlueprints.Length; i++) - { - if (checkSnappingBlueprintToNearbyObjects(movementBlueprints[i], distanceTravelled, movementBlueprintsOriginalPositions[i])) - return true; - } - } - - // if no positional snapping could be performed, try unrestricted snapping from the earliest - // item in the selection. - - // The final movement position, relative to movementBlueprintOriginalPosition. - Vector2 movePosition = movementBlueprintsOriginalPositions.First().First() + distanceTravelled; - - // Retrieve a snapped position. - var result = snapProvider?.FindSnappedPositionAndTime(movePosition, ~SnapType.NearbyObjects); - - if (result == null) - { - return SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), movePosition - movementBlueprints.First().ScreenSpaceSelectionPoint)); - } - - return ApplySnapResult(movementBlueprints, result); + return TryMoveBlueprints(e, movementBlueprints); } - /// - /// Check for positional snap for given blueprint. - /// - /// The blueprint to check for snapping. - /// Distance travelled since start of dragging action. - /// The snap positions of blueprint before start of dragging action. - /// Whether an object to snap to was found. - private bool checkSnappingBlueprintToNearbyObjects(SelectionBlueprint blueprint, Vector2 distanceTravelled, Vector2[] originalPositions) - { - var currentPositions = blueprint.ScreenSpaceSnapPoints; - - for (int i = 0; i < originalPositions.Length; i++) - { - Vector2 originalPosition = originalPositions[i]; - var testPosition = originalPosition + distanceTravelled; - - var positionalResult = snapProvider.FindSnappedPositionAndTime(testPosition, SnapType.NearbyObjects); - - if (positionalResult.ScreenSpacePosition == testPosition) continue; - - var delta = positionalResult.ScreenSpacePosition - currentPositions[i]; - - // attempt to move the objects, and abort any time based snapping if we can. - if (SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, delta))) - return true; - } - - return false; - } - - protected virtual bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) => - SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint)); + protected abstract bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints); /// /// Finishes the current movement of selected blueprints. @@ -661,7 +603,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - movementBlueprintsOriginalPositions = null; movementBlueprints = null; return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index bd750dac76..8c7afd2aeb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Graphics; @@ -16,11 +16,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public abstract partial class CircularDistanceSnapGrid : DistanceSnapGrid { - [Resolved] - private EditorClock editorClock { get; set; } = null!; - - protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) - : base(referenceObject, startPosition, startTime, endTime) + protected CircularDistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null, IHasSliderVelocity? sliderVelocitySource = null) + : base(startPosition, startTime, endTime, sliderVelocitySource) { } @@ -59,14 +56,14 @@ namespace osu.Game.Screens.Edit.Compose.Components // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the // fact that the 1/2 snap reference object is not valid for 1/3 snapping. - float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0, DistanceSnapTarget.End); + float offset = (float)(SnapProvider.FindSnappedDistance(0, StartTime, SliderVelocitySource) * DistanceSpacingMultiplier.Value); for (int i = 0; i < requiredCircles; i++) { const float thickness = 4; float diameter = (offset + (i + 1) * DistanceBetweenTicks + thickness / 2) * 2; - AddInternal(new Ring(ReferenceObject, GetColourForIndexFromPlacement(i)) + AddInternal(new Ring(StartTime, GetColourForIndexFromPlacement(i), SliderVelocitySource) { Position = StartPosition, Origin = Anchor.Centre, @@ -76,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - public override (Vector2 position, double time) GetSnappedPosition(Vector2 position) + public override (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null) { if (MaxIntervals == 0) return (StartPosition, StartTime); @@ -100,20 +97,20 @@ namespace osu.Game.Screens.Edit.Compose.Components if (travelLength < DistanceBetweenTicks) travelLength = DistanceBetweenTicks; - float snappedDistance = LimitedDistanceSnap.Value - ? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime()) + float snappedDistance = fixedTime != null + ? SnapProvider.DurationToDistance(fixedTime.Value - StartTime, StartTime, SliderVelocitySource) // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed // to allow for snapping at a non-multiplied ratio. - : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier, DistanceSnapTarget.End); + : SnapProvider.FindSnappedDistance(travelLength / distanceSpacingMultiplier, StartTime, SliderVelocitySource); - double snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); + double snappedTime = StartTime + SnapProvider.DistanceToDuration(snappedDistance, StartTime, SliderVelocitySource); if (snappedTime > LatestEndTime) { double tickLength = Beatmap.GetBeatLengthAtTime(StartTime); - snappedDistance = SnapProvider.DurationToDistance(ReferenceObject, MaxIntervals * tickLength); - snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); + snappedDistance = SnapProvider.DurationToDistance(MaxIntervals * tickLength, StartTime, SliderVelocitySource); + snappedTime = StartTime + SnapProvider.DistanceToDuration(snappedDistance, StartTime, SliderVelocitySource); } // The multiplier can then be reapplied to the final position. @@ -130,13 +127,15 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private EditorClock? editorClock { get; set; } - private readonly HitObject referenceObject; + private readonly double startTime; + private readonly IHasSliderVelocity? sliderVelocitySource; private readonly Color4 baseColour; - public Ring(HitObject referenceObject, Color4 baseColour) + public Ring(double startTime, Color4 baseColour, IHasSliderVelocity? sliderVelocitySource) { - this.referenceObject = referenceObject; + this.startTime = startTime; + this.sliderVelocitySource = sliderVelocitySource; Colour = this.baseColour = baseColour; @@ -151,9 +150,9 @@ namespace osu.Game.Screens.Edit.Compose.Components return; float distanceSpacingMultiplier = (float)snapProvider.DistanceSpacingMultiplier.Value; - double timeFromReferencePoint = editorClock.CurrentTime - referenceObject.GetEndTime(); + double timeFromReferencePoint = editorClock.CurrentTime - startTime; - float distanceForCurrentTime = snapProvider.DurationToDistance(referenceObject, timeFromReferencePoint) + float distanceForCurrentTime = snapProvider.DurationToDistance(timeFromReferencePoint, startTime, sliderVelocitySource) * distanceSpacingMultiplier; float timeBasedAlpha = 1 - Math.Clamp(Math.Abs(distanceForCurrentTime - Size.X / 2) / 30, 0, 1); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 0ffd1072cd..4414e963bf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using Humanizer; @@ -26,14 +27,13 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osuTK; -using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// /// A blueprint container generally displayed as an overlay to a ruleset's playfield. /// - public partial class ComposeBlueprintContainer : EditorBlueprintContainer + public abstract partial class ComposeBlueprintContainer : EditorBlueprintContainer { private readonly Container placementBlueprintContainer; @@ -52,7 +52,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => editorScreen?.MainContent.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); - public ComposeBlueprintContainer(HitObjectComposer composer) + protected override IEnumerable> ApplySelectionOrder(IEnumerable> blueprints) => + base.ApplySelectionOrder(blueprints) + .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); + + protected ComposeBlueprintContainer(HitObjectComposer composer) : base(composer) { placementBlueprintContainer = new Container @@ -65,11 +69,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void load() { MainTernaryStates = CreateTernaryButtons().ToArray(); - SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray(); - SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray(); - - SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); - SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); + SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -98,6 +98,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) kvp.Value.BindValueChanged(_ => updatePlacementSamples()); + + SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); + SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -108,69 +111,6 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.DrawableObject = drawableObject; } - private bool nudgeMovementActive; - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.ControlPressed) - { - switch (e.Key) - { - case Key.Left: - nudgeSelection(new Vector2(-1, 0)); - return true; - - case Key.Right: - nudgeSelection(new Vector2(1, 0)); - return true; - - case Key.Up: - nudgeSelection(new Vector2(0, -1)); - return true; - - case Key.Down: - nudgeSelection(new Vector2(0, 1)); - return true; - } - } - - return false; - } - - protected override void OnKeyUp(KeyUpEvent e) - { - base.OnKeyUp(e); - - if (nudgeMovementActive && !e.ControlPressed) - { - Beatmap.EndChange(); - nudgeMovementActive = false; - } - } - - /// - /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). - /// - /// - private void nudgeSelection(Vector2 delta) - { - if (!nudgeMovementActive) - { - nudgeMovementActive = true; - Beatmap.BeginChange(); - } - - var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); - - if (firstBlueprint == null) - return; - - // convert to game space coordinates - delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); - - SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); - } - private void updatePlacementNewCombo() { if (CurrentHitObjectPlacement?.HitObject is IHasComboInformation c) @@ -238,28 +178,40 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A collection of states which will be displayed to the user in the toolbox. /// - public TernaryButton[] MainTernaryStates { get; private set; } + public Drawable[] MainTernaryStates { get; private set; } - public TernaryButton[] SampleBankTernaryStates { get; private set; } - - public TernaryButton[] SampleAdditionBankTernaryStates { get; private set; } + public SampleBankTernaryButton[] SampleBankTernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() + protected virtual IEnumerable CreateTernaryButtons() { //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects. - yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }); + yield return new NewComboTernaryButton { Current = NewCombo }; foreach (var kvp in SelectionHandler.SelectionSampleStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => GetIconForSample(kvp.Key)); + { + yield return new DrawableTernaryButton + { + Current = kvp.Value, + Description = kvp.Key.Replace(@"hit", string.Empty).Titleize(), + CreateIcon = () => GetIconForSample(kvp.Key), + }; + } } - private IEnumerable createSampleBankTernaryButtons(Dictionary> sampleBankStates) + private IEnumerable createSampleBankTernaryButtons() { - foreach (var kvp in sampleBankStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)) + { + yield return new SampleBankTernaryButton(bankName) + { + NormalState = { Current = SelectionHandler.SelectionBankStates[bankName], }, + AdditionsState = { Current = SelectionHandler.SelectionAdditionBankStates[bankName], }, + CreateIcon = () => getIconForBank(bankName) + }; + } } private Drawable getIconForBank(string sampleName) @@ -295,19 +247,19 @@ namespace osu.Game.Screens.Edit.Compose.Components { bool enabled = SelectionHandler.AutoSelectionBankEnabled.Value; - var autoBankButton = SampleBankTernaryStates.Single(t => t.Bindable == SelectionHandler.SelectionBankStates[EditorSelectionHandler.HIT_BANK_AUTO]); - autoBankButton.Enabled.Value = enabled; - autoBankButton.Tooltip = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; + var autoBankButton = SampleBankTernaryStates.Single(t => t.BankName == EditorSelectionHandler.HIT_BANK_AUTO); + autoBankButton.NormalButton.Enabled.Value = enabled; + autoBankButton.NormalButton.TooltipText = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; } private void updateAdditionBankTernaryButtonTooltips() { bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value; - foreach (var ternaryButton in SampleAdditionBankTernaryStates) + foreach (var ternaryButton in SampleBankTernaryStates) { - ternaryButton.Enabled.Value = enabled; - ternaryButton.Tooltip = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; + ternaryButton.AdditionsButton.Enabled.Value = enabled; + ternaryButton.AdditionsButton.TooltipText = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; } } @@ -327,12 +279,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementTimeAndPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position, CurrentPlacement.SnapType); - - // if no time was found from positional snapping, we should still quantize to the beat. - snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); - - CurrentPlacement.UpdateTimeAndPosition(snapResult); + CurrentPlacement.UpdateTimeAndPosition(InputManager.CurrentState.Mouse.Position, Beatmap.SnapTime(EditorClock.CurrentTime, null)); } #endregion @@ -440,6 +387,8 @@ namespace osu.Game.Screens.Edit.Compose.Components currentTool = value; + SelectionHandler.RightClickAlwaysQuickDeletes = currentTool is not SelectTool; + // As per stable editor, when changing tools, we should forcefully commit any pending placement. CommitIfPlacementActive(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 7003d632ca..8322c67def 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -4,16 +4,17 @@ #nullable disable using System; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; -using osu.Game.Configuration; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Graphics; @@ -49,6 +50,9 @@ namespace osu.Game.Screens.Edit.Compose.Components protected readonly double? LatestEndTime; + [CanBeNull] + protected readonly IHasSliderVelocity SliderVelocitySource; + [Resolved] protected OsuColour Colours { get; private set; } @@ -61,33 +65,19 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private BindableBeatDivisor beatDivisor { get; set; } - /// - /// When enabled, distance snap should only snap to the current time (as per the editor clock). - /// This is to emulate stable behaviour. - /// - protected Bindable LimitedDistanceSnap { get; private set; } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - LimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); - } - private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); - protected readonly HitObject ReferenceObject; - /// /// Creates a new . /// - /// A reference object to gather relevant difficulty values from. /// The position at which the grid should start. The first tick is located one distance spacing length away from this point. /// The snapping time at . /// The time at which the snapping grid should end. If null, the grid will continue until the bounds of the screen are exceeded. - protected DistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) + /// The reference object with slider velocity to include in the calculations for distance snapping. + protected DistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null, [CanBeNull] IHasSliderVelocity sliderVelocitySource = null) { - ReferenceObject = referenceObject; LatestEndTime = endTime; + SliderVelocitySource = sliderVelocitySource; StartPosition = startPosition; StartTime = startTime; @@ -110,14 +100,14 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updateSpacing() { float distanceSpacingMultiplier = (float)DistanceSpacingMultiplier.Value; - float beatSnapDistance = SnapProvider.GetBeatSnapDistanceAt(ReferenceObject, false); + float beatSnapDistance = SnapProvider.GetBeatSnapDistance(SliderVelocitySource); DistanceBetweenTicks = beatSnapDistance * distanceSpacingMultiplier; if (LatestEndTime == null) MaxIntervals = int.MaxValue; else - MaxIntervals = (int)((LatestEndTime.Value - StartTime) / SnapProvider.DistanceToDuration(ReferenceObject, beatSnapDistance)); + MaxIntervals = (int)((LatestEndTime.Value - StartTime) / SnapProvider.DistanceToDuration(beatSnapDistance, StartTime, SliderVelocitySource)); gridCache.Invalidate(); } @@ -143,8 +133,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Snaps a position to this grid. /// /// The original position in coordinate space local to this . + /// + /// Whether the snap operation should be temporally constrained to a particular time instant, + /// thus fixing the possible positions to a set distance relative from the . + /// /// A tuple containing the snapped position in coordinate space local to this and the respective time value. - public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position); + public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null); /// /// Retrieves the applicable colour for a beat index. @@ -155,7 +149,18 @@ namespace osu.Game.Screens.Edit.Compose.Components { var timingPoint = Beatmap.ControlPointInfo.TimingPointAt(StartTime); double beatLength = timingPoint.BeatLength / beatDivisor.Value; - int beatIndex = (int)Math.Floor((StartTime - timingPoint.Time) / beatLength); + double fractionalBeatIndex = (StartTime - timingPoint.Time) / beatLength; + int beatIndex = (int)Math.Round(fractionalBeatIndex); + // `fractionalBeatIndex` could differ from `beatIndex` for two reasons: + // - rounding errors (which can be exacerbated by timing point start times being truncated by/for stable), + // - `StartTime` is not snapped to the beat. + // in case 1, we want rounding to occur to prevent an off-by-one, + // as `StartTime` *is* quantised to the beat. but it just doesn't look like it because floats do float things. + // in case 2, we want *flooring* to occur, to prevent a possible off-by-one + // because of the rounding snapping forward by a chunk of time significantly too high to be considered a rounding error. + // the tolerance margin chosen here is arbitrary and can be adjusted if more cases of this are found. + if (Precision.DefinitelyBigger(beatIndex, fractionalBeatIndex, 0.01)) + beatIndex = (int)Math.Floor(fractionalBeatIndex); var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 7b046251e0..e8de1eaad9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -17,7 +16,7 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Screens.Edit.Compose.Components { - public partial class EditorBlueprintContainer : BlueprintContainer + public abstract partial class EditorBlueprintContainer : BlueprintContainer { [Resolved] protected EditorClock EditorClock { get; private set; } @@ -73,27 +72,16 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints.OrderBy(b => b.Item.StartTime); - protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) + protected void ApplySnapResultTime(SnapResult result, double referenceTime) { - if (!base.ApplySnapResult(blueprints, result)) - return false; + if (!result.Time.HasValue) + return; - if (result.Time.HasValue) - { - // Apply the start time at the newly snapped-to position - double offset = result.Time.Value - blueprints.First().Item.StartTime; + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - referenceTime; - if (offset != 0) - { - Beatmap.PerformOnSelection(obj => - { - obj.StartTime += offset; - Beatmap.Update(obj); - }); - } - } - - return true; + if (offset != 0) + Beatmap.PerformOnSelection(obj => obj.StartTime += offset); } protected override void AddBlueprintFor(HitObject item) @@ -131,10 +119,6 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - protected override IEnumerable> ApplySelectionOrder(IEnumerable> blueprints) => - base.ApplySelectionOrder(blueprints) - .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); - protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 78cee2c1cf..e90936e38a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -10,16 +10,23 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { public partial class EditorSelectionHandler : SelectionHandler { + /// + /// Whether right click should delete even when shift is not held. + /// + public bool RightClickAlwaysQuickDeletes { get; set; } + /// /// A special bank name that is only used in the editor UI. /// When selected and in placement mode, the bank of the last hit object will always be used. @@ -40,6 +47,14 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectedItems.CollectionChanged += onSelectedItemsChanged; } + protected override bool ShouldQuickDelete(MouseButtonEvent e) + { + if (RightClickAlwaysQuickDeletes && e.Button == MouseButton.Right) + return true; + + return base.ShouldQuickDelete(e); + } + protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); #region Selection State @@ -79,7 +94,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// private void createStateBindables() { - foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { @@ -143,7 +158,7 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionBankStates[bankName] = bindable; } - foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { @@ -216,7 +231,7 @@ namespace osu.Game.Screens.Edit.Compose.Components resetTernaryStates(); - foreach (string sampleName in HitSampleInfo.AllAdditions) + foreach (string sampleName in HitSampleInfo.ALL_ADDITIONS) { var bindable = new Bindable { @@ -293,7 +308,8 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach ((string bankName, var bindable) in SelectionAdditionBankStates) { - bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), + h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); } } @@ -355,8 +371,6 @@ namespace osu.Game.Screens.Edit.Compose.Components for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } - - EditorBeatmap.Update(h); }); } @@ -380,18 +394,23 @@ namespace osu.Game.Screens.Edit.Compose.Components return; string normalBank = h.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; - h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + h.Samples = h.Samples.Select(s => + s.Name != HitSampleInfo.HIT_NORMAL + ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) + : s) + .ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) { normalBank = hasRepeats.NodeSamples[i].FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => + s.Name != HitSampleInfo.HIT_NORMAL + ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) + : s).ToList(); } } - - EditorBeatmap.Update(h); }); } @@ -439,8 +458,6 @@ namespace osu.Game.Screens.Edit.Compose.Components node.Add(hitSample); } } - - EditorBeatmap.Update(h); }); } @@ -462,8 +479,6 @@ namespace osu.Game.Screens.Edit.Compose.Components for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Where(s => s.Name != sampleName).ToList(); } - - EditorBeatmap.Update(h); }); } @@ -484,7 +499,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (comboInfo == null || comboInfo.NewCombo == state) return; comboInfo.NewCombo = state; - EditorBeatmap.Update(h); }); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 39fff169b7..758b712fef 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -151,14 +151,6 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public virtual SelectionRotationHandler CreateRotationHandler() => new SelectionRotationHandler(); - /// - /// Handles the selected items being scaled. - /// - /// The delta scale to apply, in local coordinates. - /// The point of reference where the scale is originating from. - /// Whether any items could be scaled. - public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; - /// /// Creates the handler to use for scale operations. /// @@ -263,15 +255,17 @@ namespace osu.Game.Screens.Edit.Compose.Components selectedBlueprints.Remove(blueprint); } + protected virtual bool ShouldQuickDelete(MouseButtonEvent e) => e.Button == MouseButton.Middle || (e.ShiftPressed && e.Button == MouseButton.Right); + /// /// Handle a blueprint requesting selection. /// /// The blueprint. /// The mouse event responsible for selection. - /// Whether a selection was performed. + /// Whether an action was performed. internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { - if (e.Button == MouseButton.Middle || (e.ShiftPressed && e.Button == MouseButton.Right)) + if (ShouldQuickDelete(e)) { handleQuickDeletion(blueprint); return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index c3a56c8df9..5e8637c1ac 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -300,7 +300,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline createStateBindables(); updateTernaryStates(); - togglesCollection.AddRange(createTernaryButtons().Select(b => new DrawableTernaryButton(b) { RelativeSizeAxes = Axes.None, Size = new Vector2(40, 40) })); + togglesCollection.AddRange(createTernaryButtons()); } private string? getCommonBank() => allRelevantSamples.Select(h => GetBankValue(h.samples)).Distinct().Count() == 1 @@ -409,7 +409,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void createStateBindables() { - foreach (string sampleName in HitSampleInfo.AllAdditions) + foreach (string sampleName in HitSampleInfo.ALL_ADDITIONS) { var bindable = new Bindable { @@ -433,7 +433,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline selectionSampleStates[sampleName] = bindable; } - banks.AddRange(HitSampleInfo.AllBanks.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); + banks.AddRange(HitSampleInfo.ALL_BANKS.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); } private void updateTernaryStates() @@ -444,10 +444,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - private IEnumerable createTernaryButtons() + private IEnumerable createTernaryButtons() { foreach ((string sampleName, var bindable) in selectionSampleStates) - yield return new TernaryButton(bindable, string.Empty, () => ComposeBlueprintContainer.GetIconForSample(sampleName)); + { + yield return new DrawableTernaryButton + { + Current = bindable, + Description = string.Empty, + CreateIcon = () => ComposeBlueprintContainer.GetIconForSample(sampleName), + RelativeSizeAxes = Axes.None, + Size = new Vector2(40, 40), + }; + } } private void addHitSample(string sampleName) @@ -516,7 +525,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); - button.Button.Toggle(); + button.Toggle(); } return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index aea8d02838..cbf49e62e7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -3,9 +3,9 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; @@ -22,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { [Cached] - public partial class Timeline : ZoomableScrollContainer, IPositionSnapProvider + public partial class Timeline : ZoomableScrollContainer { private const float timeline_height = 80; @@ -49,10 +49,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; + [Resolved] + private IBindable beatmap { get; set; } = null!; + /// /// The timeline's scroll position in the last frame. /// - private float lastScrollPosition; + private double lastScrollPosition; /// /// The track time in the last frame. @@ -86,8 +89,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private double trackLengthForZoom; - private readonly IBindable track = new Bindable(); - public Timeline(Drawable userContent) { this.userContent = userContent; @@ -101,7 +102,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [BackgroundDependencyLoader] - private void load(IBindable beatmap, OsuColour colours, OverlayColourProvider colourProvider, OsuConfigManager config) + private void load(OsuColour colours, OverlayColourProvider colourProvider, OsuConfigManager config) { CentreMarker centreMarker; @@ -150,14 +151,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline controlPointsVisible = config.GetBindable(OsuSetting.EditorTimelineShowTimingChanges); ticksVisible = config.GetBindable(OsuSetting.EditorTimelineShowTicks); - track.BindTo(editorClock.Track); - track.BindValueChanged(_ => - { - waveform.Waveform = beatmap.Value.Waveform; - Scheduler.AddOnce(applyVisualOffset, beatmap); - }, true); + editorClock.TrackChanged += updateWaveform; + updateWaveform(); - Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom); + Zoom = (float)(defaultTimelineZoom * editorBeatmap.TimelineZoom); + } + + private void updateWaveform() + { + waveform.Waveform = beatmap.Value.Waveform; + Scheduler.AddOnce(applyVisualOffset, beatmap); } private void applyVisualOffset(IBindable beatmap) @@ -212,7 +215,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float minimumZoom = getZoomLevelForVisibleMilliseconds(10000); float maximumZoom = getZoomLevelForVisibleMilliseconds(500); - float initialZoom = (float)Math.Clamp(defaultTimelineZoom * (editorBeatmap.BeatmapInfo.TimelineZoom == 0 ? 1 : editorBeatmap.BeatmapInfo.TimelineZoom), minimumZoom, maximumZoom); + float initialZoom = (float)Math.Clamp(defaultTimelineZoom * (editorBeatmap.TimelineZoom == 0 ? 1 : editorBeatmap.TimelineZoom), minimumZoom, maximumZoom); SetupZoom(initialZoom, minimumZoom, maximumZoom); @@ -234,7 +237,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void OnZoomChanged() { base.OnZoomChanged(); - editorBeatmap.BeatmapInfo.TimelineZoom = Zoom / defaultTimelineZoom; + editorBeatmap.TimelineZoom = Zoom / defaultTimelineZoom; } protected override void UpdateAfterChildren() @@ -319,7 +322,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// public double VisibleRange => editorClock.TrackLength / Zoom; - public double TimeAtPosition(float x) + public double TimeAtPosition(double x) { return x / Content.DrawWidth * editorClock.TrackLength; } @@ -329,10 +332,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return (float)(time / editorClock.TrackLength * Content.DrawWidth); } - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) { double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X); return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateWaveform; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a4083f58b6..c149a8f73a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -16,7 +16,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -89,7 +91,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); - placementBlueprint.Colour = OsuColour.Gray(0.9f); + // just to show the border. using the selection state doesn't seem to backfire. + // if it does then we'll probably want to just make `new` object above rather than rely on `CreateBlueprintFor`. + placementBlueprint.State = SelectionState.Selected; // TODO: this is out of order, causing incorrect stacking height. SelectionBlueprints.Add(placementBlueprint); @@ -106,6 +110,23 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return base.OnDragStart(e); } + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + + // Retrieve a snapped position. + var result = timeline?.FindSnappedPositionAndTime(movePosition) ?? new SnapResult(movePosition, null); + + var referenceBlueprint = blueprints.First().blueprint; + bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + if (moved) + ApplySnapResultTime(result, referenceBlueprint.Item.StartTime); + return moved; + } + private float dragTimeAccumulated; protected override void Update() @@ -131,7 +152,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateSamplePointContractedState() { - const double minimum_gap = 28; + const double absolute_minimum_gap = 31; // assumes single letter bank name for default banks + double minimumGap = absolute_minimum_gap; if (timeline == null || editorClock == null) return; @@ -153,9 +175,32 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (hitObject.GetEndTime() < editorClock.CurrentTime - timeline.VisibleRange / 2) break; + for (int i = 0; i < hitObject.Samples.Count; i++) + { + var sample = hitObject.Samples[i]; + + if (!HitSampleInfo.ALL_BANKS.Contains(sample.Bank)) + minimumGap = Math.Max(minimumGap, absolute_minimum_gap + sample.Bank.Length * 3); + } + if (hitObject is IHasRepeats hasRepeats) + { smallestTimeGap = Math.Min(smallestTimeGap, hasRepeats.Duration / hasRepeats.SpanCount() / 2); + for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) + { + var node = hasRepeats.NodeSamples[i]; + + for (int j = 0; j < node.Count; j++) + { + var sample = node[j]; + + if (!HitSampleInfo.ALL_BANKS.Contains(sample.Bank)) + minimumGap = Math.Max(minimumGap, absolute_minimum_gap + sample.Bank.Length * 3); + } + } + } + double gap = lastTime - hitObject.GetEndTime(); // If the gap is less than 1ms, we can assume that the objects are stacked on top of each other @@ -167,7 +212,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } double smallestAbsoluteGap = ((TimelineSelectionBlueprintContainer)SelectionBlueprints).ContentRelativeToAbsoluteFactor.X * smallestTimeGap; - SamplePointContracted.Value = smallestAbsoluteGap < minimum_gap; + SamplePointContracted.Value = smallestAbsoluteGap < minimumGap; } private readonly Stack currentConcurrentObjects = new Stack(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 6c0d5af247..f60d1b023b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -441,7 +441,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline double lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); int proposedCount = Math.Max(0, (int)Math.Round(proposedDuration / lengthOfOneRepeat) - 1); - if (proposedCount == repeatHitObject.RepeatCount || Precision.AlmostEquals(lengthOfOneRepeat, 0)) + if (proposedCount == repeatHitObject.RepeatCount || Precision.AlmostEquals(lengthOfOneRepeat, 0, 1)) return; repeatHitObject.RepeatCount = proposedCount; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 31a0936eb4..b483f23d1d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -141,7 +141,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (e.AltPressed) { // zoom when holding alt. - AdjustZoomRelatively(e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); + AdjustZoomRelatively(e.ScrollDelta.Y); return true; } @@ -182,7 +182,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } private void transformZoomTo(float newZoom, float focusPoint, double duration = 0, Easing easing = Easing.None) - => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, Current), newZoom, duration, easing)); + => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, (float)Current), newZoom, duration, easing)); /// /// Invoked when has changed. diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index f7e523db25..195625dcde 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -31,6 +31,9 @@ namespace osu.Game.Screens.Edit.Compose [Resolved] private IGameplaySettings globalGameplaySettings { get; set; } + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + private Bindable clipboard { get; set; } private HitObjectComposer composer; @@ -150,7 +153,7 @@ namespace osu.Game.Screens.Edit.Compose Debug.Assert(objects.Any()); - double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime); + double timeOffset = beatSnapProvider.SnapTime(clock.CurrentTime) - objects.Min(o => o.StartTime); foreach (var h in objects) h.StartTime += timeOffset; diff --git a/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.cs b/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.cs new file mode 100644 index 0000000000..1867b48830 --- /dev/null +++ b/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public partial class DiscardUnsavedChangesDialog : PopupDialog + { + public DiscardUnsavedChangesDialog(Action exit) + { + HeaderText = EditorDialogsStrings.DiscardUnsavedChangesDialogHeader; + + Icon = FontAwesome.Solid.Trash; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = EditorDialogsStrings.ForgetAllChanges, + Action = exit + }, + new PopupDialogCancelButton + { + Text = EditorDialogsStrings.ContinueEditing, + }, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 644e1afb3b..f56380a34d 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -32,6 +32,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -45,16 +46,17 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; +using osu.Game.Screens.Edit.Submission; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.Play; using osu.Game.Users; using osuTK.Input; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -63,7 +65,7 @@ namespace osu.Game.Screens.Edit { [Cached(typeof(IBeatSnapProvider))] [Cached] - public partial class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider + public partial class Editor : OsuScreen, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider { /// /// An offset applied to waveform visuals to align them with expectations. @@ -80,8 +82,6 @@ namespace osu.Game.Screens.Edit public override float BackgroundParallaxAmount => 0.1f; - public override bool AllowBackButton => false; - public override bool HideOverlaysOnEnter => true; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -113,6 +113,10 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private INotificationOverlay notifications { get; set; } + [Resolved(canBeNull: true)] + [CanBeNull] + private LoginOverlay loginOverlay { get; set; } + [Resolved] private RealmAccess realm { get; set; } @@ -160,6 +164,7 @@ namespace osu.Game.Screens.Edit private bool switchingDifficulty; private string lastSavedHash; + private EditorMenuItem discardChangesMenuItem; private ScreenContainer screenContainer; @@ -194,6 +199,8 @@ namespace osu.Game.Screens.Edit } } + protected override bool InitialBackButtonVisibility => false; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -210,6 +217,7 @@ namespace osu.Game.Screens.Edit private OnScreenDisplay onScreenDisplay { get; set; } private Bindable editorBackgroundDim; + private Bindable editorShowStoryboard; private Bindable editorHitMarkers; private Bindable editorAutoSeekOnPlacement; private Bindable editorLimitedDistanceSnap; @@ -316,10 +324,14 @@ namespace osu.Game.Screens.Edit workingBeatmapUpdated = true; }); + var bookmarkController = new BookmarkController(); + AddInternal(bookmarkController); + OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; editorBackgroundDim = config.GetBindable(OsuSetting.EditorDim); + editorShowStoryboard = config.GetBindable(OsuSetting.EditorShowStoryboard); editorHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); editorLimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); @@ -328,6 +340,18 @@ namespace osu.Game.Screens.Edit editorTimelineShowTicks = config.GetBindable(OsuSetting.EditorTimelineShowTicks); editorContractSidebars = config.GetBindable(OsuSetting.EditorContractSidebars); + // These two settings don't work together. Make them mutually exclusive to let the user know. + editorAutoSeekOnPlacement.BindValueChanged(enabled => + { + if (enabled.NewValue) + editorLimitedDistanceSnap.Value = false; + }); + editorLimitedDistanceSnap.BindValueChanged(enabled => + { + if (enabled.NewValue) + editorAutoSeekOnPlacement.Value = false; + }); + AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -398,7 +422,13 @@ namespace osu.Game.Screens.Edit }, ] }, + new OsuMenuItemSpacer(), new BackgroundDimMenuItem(editorBackgroundDim), + new ToggleMenuItem("Show storyboard") + { + State = { BindTarget = editorShowStoryboard }, + }, + new OsuMenuItemSpacer(), new ToggleMenuItem(EditorStrings.ShowHitMarkers) { State = { BindTarget = editorHitMarkers }, @@ -422,6 +452,7 @@ namespace osu.Game.Screens.Edit Items = new MenuItem[] { new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime), + bookmarkController.Menu, } } } @@ -443,12 +474,14 @@ namespace osu.Game.Screens.Edit changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); - editorBackgroundDim.BindValueChanged(_ => dimBackground()); + editorBackgroundDim.BindValueChanged(_ => setUpBackground()); } [Resolved] private MusicController musicController { get; set; } + protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap.Value); + protected override void LoadComplete() { base.LoadComplete(); @@ -500,6 +533,8 @@ namespace osu.Game.Screens.Edit public void TestGameplay() { + clock.Stop(); + if (HasUnsavedChanges) { dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => @@ -573,6 +608,8 @@ namespace osu.Game.Screens.Edit { base.Update(); clock.ProcessFrame(); + + discardChangesMenuItem.Action.Disabled = !HasUnsavedChanges; } public bool OnPressed(KeyBindingPressEvent e) @@ -760,11 +797,6 @@ namespace osu.Game.Screens.Edit switch (e.Action) { - case GlobalAction.Back: - // as we don't want to display the back button, manual handling of exit action is required. - this.Exit(); - return true; - case GlobalAction.EditorCloneSelection: Clone(); return true; @@ -792,6 +824,10 @@ namespace osu.Game.Screens.Edit case GlobalAction.EditorTestGameplay: bottomBar.TestGameplayButton.TriggerClick(); return true; + + case GlobalAction.EditorDiscardUnsavedChanges: + DiscardUnsavedChanges(); + return true; } return false; @@ -804,23 +840,23 @@ namespace osu.Game.Screens.Edit public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); - dimBackground(); + setUpBackground(); resetTrack(true); } public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); - dimBackground(); + setUpBackground(); + clock.BindAdjustments(); } - private void dimBackground() + private void setUpBackground() { ApplyToBackground(b => { - b.IgnoreUserSettings.Value = true; - b.DimWhenUserSettingsIgnored.Value = editorBackgroundDim.Value; - b.BlurAmount.Value = 0; + var editorBackground = (EditorBackgroundScreen)b; + editorBackground.ChangeClockSource(clock); }); } @@ -859,11 +895,6 @@ namespace osu.Game.Screens.Edit beatmap.EditorTimestamp = clock.CurrentTime; }); - ApplyToBackground(b => - { - b.DimWhenUserSettingsIgnored.Value = 0; - }); - resetTrack(); refetchBeatmap(); @@ -876,6 +907,10 @@ namespace osu.Game.Screens.Edit base.OnSuspending(e); clock.Stop(); refetchBeatmap(); + // unfortunately ordering matters here. + // this unbind MUST happen after `refetchBeatmap()`, because along other things, `refetchBeatmap()` causes a global working beatmap change, + // which causes `EditorClock` to reload the track and automatically reapply adjustments to it. + clock.UnbindAdjustments(); } private void refetchBeatmap() @@ -980,6 +1015,20 @@ namespace osu.Game.Screens.Edit protected void Redo() => changeHandler?.RestoreState(1); + protected void DiscardUnsavedChanges() + { + if (!HasUnsavedChanges) + return; + + // we're not doing this via `changeHandler` because `changeHandler` has limited number of undo actions + // and therefore there's no guarantee that it even *has* the beatmap's last saved state in its history still. + dialogOverlay.Push(new DiscardUnsavedChangesDialog(() => + { + updateLastSavedHash(); // without this a second dialog will show (the standard "save unsaved changes" one that shows on exit). + SwitchToDifficulty(editorBeatmap.BeatmapInfo); + })); + } + protected void SetPreviewPointToCurrentTime() { editorBeatmap.PreviewTime.Value = (int)clock.CurrentTime; @@ -1220,17 +1269,45 @@ namespace osu.Game.Screens.Edit saveRelatedMenuItems.Add(save); yield return save; - if (RuntimeInfo.IsDesktop) + yield return discardChangesMenuItem = new EditorMenuItem(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges, MenuItemType.Destructive, DiscardUnsavedChanges) + { + Hotkey = new Hotkey(GlobalAction.EditorDiscardUnsavedChanges) + }; + + if (RuntimeInfo.OS != RuntimeInfo.Platform.Android) { var export = createExportMenu(); saveRelatedMenuItems.AddRange(export.Items); yield return export; + } - var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); + if (RuntimeInfo.IsDesktop) + { + var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally); saveRelatedMenuItems.Add(externalEdit); yield return externalEdit; } + bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset()) + || (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset())); + bool submissionAvailable = api.Endpoints.BeatmapSubmissionServiceUrl != null; + + if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable) + { + var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap); + saveRelatedMenuItems.Add(upload); + yield return upload; + } + + if (editorBeatmap.BeatmapInfo.OnlineID > 0) + { + yield return new OsuMenuItemSpacer(); + yield return new EditorMenuItem(EditorStrings.OpenInfoPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally(editorBeatmap.BeatmapInfo.GetOnlineURL(api, editorBeatmap.BeatmapInfo.Ruleset))); + yield return new EditorMenuItem(EditorStrings.OpenDiscussionPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}")); + } + yield return new OsuMenuItemSpacer(); yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); } @@ -1270,6 +1347,42 @@ namespace osu.Game.Screens.Edit } } + private void submitBeatmap() + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + if (!editorBeatmap.HitObjects.Any()) + { + notifications?.Post(new SimpleNotification + { + Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted, + }); + return; + } + + if (HasUnsavedChanges) + { + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => + { + if (!Save()) + return false; + + startSubmission(); + return true; + }))); + } + else + { + startSubmission(); + } + + void startSubmission() => this.Push(new BeatmapSubmissionScreen()); + } + private void exportBeatmap(bool legacy) { if (HasUnsavedChanges) @@ -1423,11 +1536,11 @@ namespace osu.Game.Screens.Edit loader?.CancelPendingDifficultySwitch(); } - public Task Reload() + public Task SaveAndReload() { var tcs = new TaskCompletionSource(); - dialogOverlay.Push(new ReloadEditorDialog( + dialogOverlay.Push(new SaveAndReloadEditorDialog( reload: () => { bool reloadedSuccessfully = attemptMutationOperation(() => diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index ad31c2ccc3..91ae4593dd 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -13,6 +13,7 @@ using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Edit; @@ -118,6 +119,14 @@ namespace osu.Game.Screens.Edit playableBeatmap.Breaks.AddRange(Breaks); }); + Bookmarks = new BindableList(playableBeatmap.Bookmarks); + Bookmarks.BindCollectionChanged((_, _) => + { + BeginChange(); + playableBeatmap.Bookmarks = Bookmarks.OrderBy(x => x).Distinct().ToArray(); + EndChange(); + }); + PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime); PreviewTime.BindValueChanged(s => { @@ -125,6 +134,8 @@ namespace osu.Game.Screens.Edit BeatmapInfo.Metadata.PreviewTime = s.NewValue; EndChange(); }); + + BeatmapVersion = PlayableBeatmap.BeatmapVersion; } /// @@ -198,6 +209,88 @@ namespace osu.Game.Screens.Edit public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength(); + public double AudioLeadIn + { + get => PlayableBeatmap.AudioLeadIn; + set => PlayableBeatmap.AudioLeadIn = value; + } + + public float StackLeniency + { + get => PlayableBeatmap.StackLeniency; + set => PlayableBeatmap.StackLeniency = value; + } + + public bool SpecialStyle + { + get => PlayableBeatmap.SpecialStyle; + set => PlayableBeatmap.SpecialStyle = value; + } + + public bool LetterboxInBreaks + { + get => PlayableBeatmap.LetterboxInBreaks; + set => PlayableBeatmap.LetterboxInBreaks = value; + } + + public bool WidescreenStoryboard + { + get => PlayableBeatmap.WidescreenStoryboard; + set => PlayableBeatmap.WidescreenStoryboard = value; + } + + public bool EpilepsyWarning + { + get => PlayableBeatmap.EpilepsyWarning; + set => PlayableBeatmap.EpilepsyWarning = value; + } + + public bool SamplesMatchPlaybackRate + { + get => PlayableBeatmap.SamplesMatchPlaybackRate; + set => PlayableBeatmap.SamplesMatchPlaybackRate = value; + } + + public double DistanceSpacing + { + get => PlayableBeatmap.DistanceSpacing; + set => PlayableBeatmap.DistanceSpacing = value; + } + + public int GridSize + { + get => PlayableBeatmap.GridSize; + set => PlayableBeatmap.GridSize = value; + } + + public double TimelineZoom + { + get => PlayableBeatmap.TimelineZoom; + set => PlayableBeatmap.TimelineZoom = value; + } + + public CountdownType Countdown + { + get => PlayableBeatmap.Countdown; + set => PlayableBeatmap.Countdown = value; + } + + public int CountdownOffset + { + get => PlayableBeatmap.CountdownOffset; + set => PlayableBeatmap.CountdownOffset = value; + } + + public readonly BindableList Bookmarks; + + int[] IBeatmap.Bookmarks + { + get => PlayableBeatmap.Bookmarks; + set => PlayableBeatmap.Bookmarks = value; + } + + public int BeatmapVersion { get; set; } + public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; @@ -224,8 +317,13 @@ namespace osu.Game.Screens.Edit return; BeginChange(); + foreach (var h in SelectedHitObjects) + { action(h); + Update(h); + } + EndChange(); } @@ -363,6 +461,10 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0) return; + // if the user is doing edits to this beatmaps via this flow, we better bump the beatmap version + // because the beatmap encoder can only output this specific beatmap version anyway, + // so *not* bumping it could lead to results that look misleading at best. + BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION; beatmapProcessor.PreProcess(); foreach (var h in batchPendingDeletes) processHitObject(h); diff --git a/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs index 4fe431498f..957c1d0969 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs @@ -41,6 +41,7 @@ namespace osu.Game.Screens.Edit rulesetBeatmapProcessor?.PostProcess(); autoGenerateBreaks(); + ensureNewComboAfterBreaks(); } private void autoGenerateBreaks() @@ -100,5 +101,40 @@ namespace osu.Game.Screens.Edit Beatmap.Breaks.Add(breakPeriod); } } + + private void ensureNewComboAfterBreaks() + { + var breakEnds = Beatmap.Breaks.Select(b => b.EndTime).OrderBy(t => t).ToList(); + + if (breakEnds.Count == 0) + return; + + int currentBreak = 0; + + IHasComboInformation? lastObj = null; + bool comboInformationUpdateRequired = false; + + foreach (var hitObject in Beatmap.HitObjects) + { + if (hitObject is not IHasComboInformation hasCombo) + continue; + + if (currentBreak < breakEnds.Count && hitObject.StartTime >= breakEnds[currentBreak]) + { + if (!hasCombo.NewCombo) + { + hasCombo.NewCombo = true; + comboInformationUpdateRequired = true; + } + + currentBreak += 1; + } + + if (comboInformationUpdateRequired) + hasCombo.UpdateComboInformation(lastObj); + + lastObj = hasCombo; + } + } } } diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 5b9c662c95..8b9bdb595d 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -6,6 +6,8 @@ using System; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -23,12 +25,15 @@ namespace osu.Game.Screens.Edit /// public partial class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { - public IBindable Track => track; + [CanBeNull] + public event Action TrackChanged; private readonly Bindable track = new Bindable(); public double TrackLength => track.Value?.IsLoaded == true ? track.Value.Length : 60000; + public AudioAdjustments AudioAdjustments { get; } = new AudioAdjustments(); + public ControlPointInfo ControlPointInfo => Beatmap.ControlPointInfo; public IBeatmap Beatmap { get; set; } @@ -56,6 +61,8 @@ namespace osu.Game.Screens.Edit underlyingClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: true); AddInternal(underlyingClock); + + track.BindValueChanged(_ => TrackChanged?.Invoke()); } /// @@ -208,7 +215,16 @@ namespace osu.Game.Screens.Edit } } - public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); + public void BindAdjustments() => track.Value?.BindAdjustments(AudioAdjustments); + + public void UnbindAdjustments() => track.Value?.UnbindAdjustments(AudioAdjustments); + + public void ResetSpeedAdjustments() + { + AudioAdjustments.RemoveAllAdjustments(AdjustableProperty.Frequency); + AudioAdjustments.RemoveAllAdjustments(AdjustableProperty.Tempo); + underlyingClock.ResetSpeedAdjustments(); + } double IAdjustableClock.Rate { @@ -231,8 +247,12 @@ namespace osu.Game.Screens.Edit public void ChangeSource(IClock source) { + UnbindAdjustments(); + track.Value = source as Track; underlyingClock.ChangeSource(source); + + BindAdjustments(); } public IClock Source => underlyingClock.Source; diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 0e0fb9f795..7c6ee10840 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.Edit public override float BackgroundParallaxAmount => 0.1f; - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool HideOverlaysOnEnter => true; diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 4377cc6219..60cb3ba07c 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +14,9 @@ using osu.Game.Overlays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Users; namespace osu.Game.Screens.Edit.GameplayTest @@ -228,5 +231,7 @@ namespace osu.Game.Screens.Edit.GameplayTest editor.RestoreState(editorState); return base.OnExiting(e); } + + protected override ResultsScreen CreateResults(ScoreInfo score) => throw new NotSupportedException(); } } diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index a1ee41fc48..e84b6bfc72 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Edit processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); processBreaks(() => newBeatmap ??= readBeatmap(newState)); + processBookmarks(() => newBeatmap ??= readBeatmap(newState)); processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState)); editorBeatmap.EndChange(); } @@ -97,6 +98,29 @@ namespace osu.Game.Screens.Edit } } + private void processBookmarks(Func getNewBeatmap) + { + var newBookmarks = getNewBeatmap().Bookmarks.ToHashSet(); + + foreach (int oldBookmark in editorBeatmap.Bookmarks.ToArray()) + { + if (newBookmarks.Contains(oldBookmark)) + continue; + + editorBeatmap.Bookmarks.Remove(oldBookmark); + } + + foreach (int newBookmark in newBookmarks) + { + if (editorBeatmap.Bookmarks.Contains(newBookmark)) + continue; + + int idx = editorBeatmap.Bookmarks.BinarySearch(newBookmark); + if (idx < 0) + editorBeatmap.Bookmarks.Insert(~idx, newBookmark); + } + } + private void processHitObjects(DiffResult result, Func getNewBeatmap) { findChangedIndices(result, LegacyDecoder.Section.HitObjects, out var removedIndices, out var addedIndices); diff --git a/osu.Game/Screens/Edit/ReloadEditorDialog.cs b/osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs similarity index 86% rename from osu.Game/Screens/Edit/ReloadEditorDialog.cs rename to osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs index 72a9f81347..b73c7cfff8 100644 --- a/osu.Game/Screens/Edit/ReloadEditorDialog.cs +++ b/osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs @@ -8,9 +8,9 @@ using osu.Game.Localisation; namespace osu.Game.Screens.Edit { - public partial class ReloadEditorDialog : PopupDialog + public partial class SaveAndReloadEditorDialog : PopupDialog { - public ReloadEditorDialog(Action reload, Action cancel) + public SaveAndReloadEditorDialog(Action reload, Action cancel) { HeaderText = EditorDialogsStrings.EditorReloadDialogHeader; diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 8de7f86523..865fe05c54 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Beatmaps.Formats; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Skinning; @@ -54,6 +55,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapSkin.ComboColours.Clear(); Beatmap.BeatmapSkin.ComboColours.AddRange(comboColours.Colours); + updateAddButtonVisibility(); + syncingColours = false; } }); @@ -68,8 +71,14 @@ namespace osu.Game.Screens.Edit.Setup comboColours.Colours.Clear(); comboColours.Colours.AddRange(Beatmap.BeatmapSkin?.ComboColours); + updateAddButtonVisibility(); + syncingColours = false; }); + + updateAddButtonVisibility(); + + void updateAddButtonVisibility() => comboColours.CanAdd.Value = comboColours.Colours.Count < LegacyBeatmapDecoder.MAX_COMBO_COLOUR_COUNT; } } } diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index 7def5394e6..f9c9835849 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Edit.Setup { Caption = EditorSetupStrings.EnableCountdown, HintText = EditorSetupStrings.CountdownDescription, - Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, + Current = { Value = Beatmap.Countdown != CountdownType.None }, }, CountdownSettings = new FillFlowContainer { @@ -52,14 +52,14 @@ namespace osu.Game.Screens.Edit.Setup CountdownSpeed = new FormEnumDropdown { Caption = EditorSetupStrings.CountdownSpeed, - Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, + Current = { Value = Beatmap.Countdown != CountdownType.None ? Beatmap.Countdown : CountdownType.Normal }, Items = Enum.GetValues().Where(type => type != CountdownType.None) }, CountdownOffset = new FormNumberBox { Caption = EditorSetupStrings.CountdownOffset, HintText = EditorSetupStrings.CountdownOffsetDescription, - Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, + Current = { Value = Beatmap.CountdownOffset.ToString() }, TabbableContentContainer = this, } } @@ -68,25 +68,25 @@ namespace osu.Game.Screens.Edit.Setup { Caption = EditorSetupStrings.WidescreenSupport, HintText = EditorSetupStrings.WidescreenSupportDescription, - Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } + Current = { Value = Beatmap.WidescreenStoryboard } }, epilepsyWarning = new FormCheckBox { Caption = EditorSetupStrings.EpilepsyWarning, HintText = EditorSetupStrings.EpilepsyWarningDescription, - Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } + Current = { Value = Beatmap.EpilepsyWarning } }, letterboxDuringBreaks = new FormCheckBox { Caption = EditorSetupStrings.LetterboxDuringBreaks, HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } + Current = { Value = Beatmap.LetterboxInBreaks } }, samplesMatchPlaybackRate = new FormCheckBox { Caption = EditorSetupStrings.SamplesMatchPlaybackRate, HintText = EditorSetupStrings.SamplesMatchPlaybackRateDescription, - Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } + Current = { Value = Beatmap.SamplesMatchPlaybackRate } } }; } @@ -113,18 +113,18 @@ namespace osu.Game.Screens.Edit.Setup { updateBeatmap(); // update displayed text to ensure parsed value matches display (i.e. if empty string was provided). - CountdownOffset.Current.Value = Beatmap.BeatmapInfo.CountdownOffset.ToString(CultureInfo.InvariantCulture); + CountdownOffset.Current.Value = Beatmap.CountdownOffset.ToString(CultureInfo.InvariantCulture); } private void updateBeatmap() { - Beatmap.BeatmapInfo.Countdown = EnableCountdown.Current.Value ? CountdownSpeed.Current.Value : CountdownType.None; - Beatmap.BeatmapInfo.CountdownOffset = int.TryParse(CountdownOffset.Current.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int offset) ? offset : 0; + Beatmap.Countdown = EnableCountdown.Current.Value ? CountdownSpeed.Current.Value : CountdownType.None; + Beatmap.CountdownOffset = int.TryParse(CountdownOffset.Current.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int offset) ? offset : 0; - Beatmap.BeatmapInfo.WidescreenStoryboard = widescreenSupport.Current.Value; - Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value; - Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; - Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; + Beatmap.WidescreenStoryboard = widescreenSupport.Current.Value; + Beatmap.EpilepsyWarning = epilepsyWarning.Current.Value; + Beatmap.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; + Beatmap.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; Beatmap.SaveState(); } diff --git a/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs b/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs new file mode 100644 index 0000000000..53287383ec --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit.Setup +{ + /// + /// A type of dedicated to beatmap resources. + /// + /// + /// This expands on by adding an intermediate step before finalisation + /// to choose whether the selected file should be applied to the current difficulty or all difficulties in the set, + /// the user's choice is saved in before the file selection is finalised and propagated to . + /// + public partial class FormBeatmapFileSelector : FormFileSelector + { + private readonly bool beatmapHasMultipleDifficulties; + + public readonly Bindable ApplyToAllDifficulties = new Bindable(true); + + public FormBeatmapFileSelector(bool beatmapHasMultipleDifficulties, params string[] handledExtensions) + : base(handledExtensions) + { + this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties; + } + + protected override FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) + { + var popover = new BeatmapFileChooserPopover(handledExtensions, current, chooserPath, beatmapHasMultipleDifficulties); + popover.ApplyToAllDifficulties.BindTo(ApplyToAllDifficulties); + return popover; + } + + private partial class BeatmapFileChooserPopover : FileChooserPopover + { + private readonly bool beatmapHasMultipleDifficulties; + + public readonly Bindable ApplyToAllDifficulties = new Bindable(true); + + private Container selectApplicationScopeContainer = null!; + + public BeatmapFileChooserPopover(string[] handledExtensions, Bindable current, string? chooserPath, bool beatmapHasMultipleDifficulties) + : base(handledExtensions, current, chooserPath) + { + this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Add(selectApplicationScopeContainer = new InputBlockingContainer + { + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background6.Opacity(0.9f), + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 10f, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Margin = new MarginPadding(30), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = EditorSetupStrings.ApplicationScopeSelectionTitle, + Margin = new MarginPadding { Bottom = 20f }, + }, + new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300f, + Text = EditorSetupStrings.ApplyToAllDifficulties, + Action = () => + { + ApplyToAllDifficulties.Value = true; + updateFileSelection(); + }, + BackgroundColour = colours.Red2, + }, + new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300f, + Text = EditorSetupStrings.ApplyToThisDifficulty, + Action = () => + { + ApplyToAllDifficulties.Value = false; + updateFileSelection(); + }, + }, + } + } + } + }, + } + }); + } + + protected override void OnFileSelected(FileInfo file) + { + if (beatmapHasMultipleDifficulties) + selectApplicationScopeContainer.FadeIn(200, Easing.InQuint); + else + base.OnFileSelected(file); + } + + private void updateFileSelection() + { + Debug.Assert(FileSelector.CurrentFile.Value != null); + base.OnFileSelected(FileSelector.CurrentFile.Value); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 20c0a74d84..85247bc15a 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; @@ -28,33 +29,31 @@ namespace osu.Game.Screens.Edit.Setup public override LocalisableString Title => EditorSetupStrings.MetadataHeader; [BackgroundDependencyLoader] - private void load() + private void load(SetupScreen? setupScreen) { - var metadata = Beatmap.Metadata; - Children = new[] { - ArtistTextBox = createTextBox(EditorSetupStrings.Artist, - !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, - !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - TitleTextBox = createTextBox(EditorSetupStrings.Title, - !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, - !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), - difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), - sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) + ArtistTextBox = createTextBox(EditorSetupStrings.Artist), + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist), + TitleTextBox = createTextBox(EditorSetupStrings.Title), + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle), + creatorTextBox = createTextBox(EditorSetupStrings.Creator), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName), + sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource), + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags) }; + + if (setupScreen != null) + setupScreen.MetadataChanged += reloadMetadata; + + reloadMetadata(); } - private TTextBox createTextBox(LocalisableString label, string initialValue) + private TTextBox createTextBox(LocalisableString label) where TTextBox : FormTextBox, new() => new TTextBox { Caption = label, - Current = { Value = initialValue }, TabbableContentContainer = this }; @@ -94,10 +93,29 @@ namespace osu.Game.Screens.Edit.Setup // for now, update on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - updateMetadata(); + setMetadata(); } - private void updateMetadata() + private void reloadMetadata() + { + var metadata = Beatmap.Metadata; + + RomanisedArtistTextBox.ReadOnly = false; + RomanisedTitleTextBox.ReadOnly = false; + + ArtistTextBox.Current.Value = !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist; + RomanisedArtistTextBox.Current.Value = !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode); + TitleTextBox.Current.Value = !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title; + RomanisedTitleTextBox.Current.Value = !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode); + creatorTextBox.Current.Value = metadata.Author.Username; + difficultyTextBox.Current.Value = Beatmap.BeatmapInfo.DifficultyName; + sourceTextBox.Current.Value = metadata.Source; + tagsTextBox.Current.Value = metadata.Tags; + + updateReadOnlyState(); + } + + private void setMetadata() { Beatmap.Metadata.ArtistUnicode = ArtistTextBox.Current.Value; Beatmap.Metadata.Artist = RomanisedArtistTextBox.Current.Value; @@ -119,7 +137,10 @@ namespace osu.Game.Screens.Edit.Setup private partial class RomanisedTextBox : InnerTextBox { - protected override bool AllowIme => false; + public RomanisedTextBox() + { + InputProperties = new TextInputProperties(TextInputType.Text, false); + } protected override bool CanAddCharacter(char character) => MetadataUtils.IsRomanised(character); diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 845c21b598..cab6eddaa4 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -1,22 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.IO; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Localisation; +using osu.Game.Models; +using osu.Game.Screens.Backgrounds; +using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup { public partial class ResourcesSection : SetupSection { - private FormFileSelector audioTrackChooser = null!; - private FormFileSelector backgroundChooser = null!; + private FormBeatmapFileSelector audioTrackChooser = null!; + private FormBeatmapFileSelector backgroundChooser = null!; public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; @@ -30,10 +35,10 @@ namespace osu.Game.Screens.Edit.Setup private IBindable working { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; + private Editor? editor { get; set; } [Resolved] - private Editor? editor { get; set; } + private SetupScreen setupScreen { get; set; } = null!; private SetupScreenHeaderBackground headerBackground = null!; @@ -46,14 +51,16 @@ namespace osu.Game.Screens.Edit.Setup Height = 110, }; + bool beatmapHasMultipleDifficulties = working.Value.BeatmapSetInfo.Beatmaps.Count > 1; + Children = new Drawable[] { - backgroundChooser = new FormFileSelector(".jpg", ".jpeg", ".png") + backgroundChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.IMAGE_EXTENSIONS) { Caption = GameplaySettingsStrings.BackgroundHeader, PlaceholderText = EditorSetupStrings.ClickToSelectBackground, }, - audioTrackChooser = new FormFileSelector(".mp3", ".ogg") + audioTrackChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.AUDIO_EXTENSIONS) { Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, @@ -72,76 +79,164 @@ namespace osu.Game.Screens.Edit.Setup audioTrackChooser.Current.BindValueChanged(audioTrackChanged); } - public bool ChangeBackgroundImage(FileInfo source) + public bool ChangeBackgroundImage(FileInfo source, bool applyToAllDifficulties) { if (!source.Exists) return false; - var set = working.Value.BeatmapSetInfo; + changeResource(source, applyToAllDifficulties, @"bg", + metadata => metadata.BackgroundFile, + (metadata, name) => metadata.BackgroundFile = name); - var destination = new FileInfo($@"bg{source.Extension}"); - - // remove the previous background for now. - // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.GetFile(working.Value.Metadata.BackgroundFile); - - using (var stream = source.OpenRead()) - { - if (oldFile != null) - beatmaps.DeleteFile(set, oldFile); - - beatmaps.AddFile(set, stream, destination.Name); - } - - editorBeatmap.SaveState(); - - working.Value.Metadata.BackgroundFile = destination.Name; headerBackground.UpdateBackground(); - - editor?.ApplyToBackground(bg => bg.RefreshBackground()); - + editor?.ApplyToBackground(bg => ((EditorBackgroundScreen)bg).RefreshBackground()); return true; } - public bool ChangeAudioTrack(FileInfo source) + public bool ChangeAudioTrack(FileInfo source, bool applyToAllDifficulties) { if (!source.Exists) return false; - var set = working.Value.BeatmapSetInfo; + TagLib.File? tagSource; - var destination = new FileInfo($@"audio{source.Extension}"); - - // remove the previous audio track for now. - // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.GetFile(working.Value.Metadata.AudioFile); - - using (var stream = source.OpenRead()) + try { - if (oldFile != null) - beatmaps.DeleteFile(set, oldFile); - - beatmaps.AddFile(set, stream, destination.Name); + tagSource = TagLib.File.Create(source.FullName); + } + catch (Exception e) + { + Logger.Error(e, "The selected audio track appears to be corrupted. Please select another one."); + return false; } - working.Value.Metadata.AudioFile = destination.Name; + changeResource(source, applyToAllDifficulties, @"audio", + metadata => metadata.AudioFile, + (metadata, name) => + { + metadata.AudioFile = name; + + string artist = tagSource.Tag.JoinedAlbumArtists; + + if (!string.IsNullOrWhiteSpace(artist)) + { + metadata.ArtistUnicode = artist; + metadata.Artist = MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode); + } + + string title = tagSource.Tag.Title; + + if (!string.IsNullOrEmpty(title)) + { + metadata.TitleUnicode = title; + metadata.Title = MetadataUtils.StripNonRomanisedCharacters(metadata.TitleUnicode); + } + }); - editorBeatmap.SaveState(); music.ReloadCurrentTrack(); - + setupScreen.MetadataChanged?.Invoke(); return true; } + private void changeResource(FileInfo source, bool applyToAllDifficulties, string baseFilename, Func readFilename, Action writeMetadata) + { + var set = working.Value.BeatmapSetInfo; + var beatmap = working.Value.BeatmapInfo; + + var otherBeatmaps = set.Beatmaps.Where(b => !b.Equals(beatmap)); + + // First, clean up files which will no longer be used. + if (applyToAllDifficulties) + { + foreach (var b in set.Beatmaps) + { + if (set.GetFile(readFilename(b.Metadata)) is RealmNamedFileUsage otherExistingFile) + beatmaps.DeleteFile(set, otherExistingFile); + } + } + else + { + RealmNamedFileUsage? oldFile = set.GetFile(readFilename(working.Value.Metadata)); + + if (oldFile != null) + { + bool oldFileUsedInOtherDiff = otherBeatmaps + .Any(b => readFilename(b.Metadata) == oldFile.Filename); + if (!oldFileUsedInOtherDiff) + beatmaps.DeleteFile(set, oldFile); + } + } + + // Choose a new filename that doesn't clash with any other existing files. + string newFilename = $"{baseFilename}{source.Extension}"; + + if (set.GetFile(newFilename) != null) + { + string[] existingFilenames = set.Files.Select(f => f.Filename).Where(f => + f.StartsWith(baseFilename, StringComparison.OrdinalIgnoreCase) && + f.EndsWith(source.Extension, StringComparison.OrdinalIgnoreCase)).ToArray(); + newFilename = NamingUtils.GetNextBestFilename(existingFilenames, $@"{baseFilename}{source.Extension}"); + } + + using (var stream = source.OpenRead()) + beatmaps.AddFile(set, stream, newFilename); + + if (applyToAllDifficulties) + { + foreach (var b in otherBeatmaps) + { + writeMetadata(b.Metadata, newFilename); + + // save the difficulty to re-encode the .osu file, updating any reference of the old filename. + // + // note that this triggers a full save flow, including triggering a difficulty calculation. + // this is not a cheap operation and should be reconsidered in the future. + var beatmapWorking = beatmaps.GetWorkingBeatmap(b); + beatmaps.Save(b, beatmapWorking.Beatmap, beatmapWorking.GetSkin()); + } + } + + writeMetadata(beatmap.Metadata, newFilename); + + // editor change handler cannot be aware of any file changes or other difficulties having their metadata modified. + // for simplicity's sake, trigger a save when changing any resource to ensure the change is correctly saved. + editor?.Save(); + } + + // to avoid scaring users, both background & audio choosers use fake `FileInfo`s with user-friendly filenames + // when displaying an imported beatmap rather than the actual SHA-named file in storage. + // however, that means that when a background or audio file is chosen that is broken or doesn't exist on disk when switching away from the fake files, + // the rollback could enter an infinite loop, because the fake `FileInfo`s *also* don't exist on disk - at least not in the fake location they indicate. + // to circumvent this issue, just allow rollback to proceed always without actually running any of the change logic to ensure visual consistency. + // note that this means that `Change{BackgroundImage,AudioTrack}()` are required to not have made any modifications to the beatmap files + // (or at least cleaned them up properly themselves) if they return `false`. + private bool rollingBackBackgroundChange; + private bool rollingBackAudioChange; + private void backgroundChanged(ValueChangedEvent file) { - if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue)) + if (rollingBackBackgroundChange) + return; + + if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue, backgroundChooser.ApplyToAllDifficulties.Value)) + { + rollingBackBackgroundChange = true; backgroundChooser.Current.Value = file.OldValue; + rollingBackBackgroundChange = false; + } } private void audioTrackChanged(ValueChangedEvent file) { - if (file.NewValue == null || !ChangeAudioTrack(file.NewValue)) + if (rollingBackAudioChange) + return; + + if (file.NewValue == null || !ChangeAudioTrack(file.NewValue, audioTrackChooser.ApplyToAllDifficulties.Value)) + { + rollingBackAudioChange = true; audioTrackChooser.Current.Value = file.OldValue; + rollingBackAudioChange = false; + } } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index f8c4998263..97e12ae096 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,12 +14,15 @@ using osuTK; namespace osu.Game.Screens.Edit.Setup { + [Cached] public partial class SetupScreen : EditorScreen { public const float COLUMN_WIDTH = 450; public const float SPACING = 28; public const float MAX_WIDTH = 2 * COLUMN_WIDTH + SPACING; + public Action? MetadataChanged { get; set; } + public SetupScreen() : base(EditorScreenMode.SongSetup) { diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs new file mode 100644 index 0000000000..cf2fef25d5 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class BeatmapSubmissionOverlay : WizardOverlay + { + public BeatmapSubmissionOverlay() + : base(OverlayColourScheme.Aquamarine) + { + } + + [BackgroundDependencyLoader] + private void load(IBindable beatmap) + { + if (beatmap.Value.BeatmapSetInfo.OnlineID <= 0) + { + AddStep(); + AddStep(); + } + + AddStep(); + + Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle; + Header.Description = BeatmapSubmissionStrings.BeatmapSubmissionDescription; + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs new file mode 100644 index 0000000000..2ea710d3ab --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -0,0 +1,447 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO.Archives; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class BeatmapSubmissionScreen : OsuScreen + { + private BeatmapSubmissionOverlay overlay = null!; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + protected override bool InitialBackButtonVisibility => false; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private Storage storage { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Cached] + private BeatmapSubmissionSettings settings { get; } = new BeatmapSubmissionSettings(); + + private Container submissionProgress = null!; + private SubmissionStageProgress exportStep = null!; + private SubmissionStageProgress createSetStep = null!; + private SubmissionStageProgress uploadStep = null!; + private SubmissionStageProgress updateStep = null!; + private Container successContainer = null!; + private Container flashLayer = null!; + + private uint? beatmapSetId; + private MemoryStream? beatmapPackageStream; + + private ProgressNotification? exportProgressNotification; + private ProgressNotification? updateProgressNotification; + + private Live? importedSet; + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + overlay = new BeatmapSubmissionOverlay(), + submissionProgress = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AutoSizeDuration = 400, + AutoSizeEasing = Easing.OutQuint, + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.6f, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(20), + Spacing = new Vector2(5), + Children = new Drawable[] + { + createSetStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Preparing, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + exportStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Exporting, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + uploadStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Uploading, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + updateStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Finishing, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + successContainer = new Container + { + Padding = new MarginPadding(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Child = flashLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Depth = float.MinValue, + Alpha = 0, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + } + } + } + } + }); + + overlay.State.BindValueChanged(_ => + { + if (overlay.State.Value == Visibility.Hidden) + { + if (!overlay.Completed) + { + allowExit(); + this.Exit(); + } + else + { + submissionProgress.FadeIn(200, Easing.OutQuint); + createBeatmapSet(); + } + } + }); + } + + private void createBeatmapSet() + { + bool beatmapHasOnlineId = Beatmap.Value.BeatmapSetInfo.OnlineID > 0; + + var createRequest = beatmapHasOnlineId + ? PutBeatmapSetRequest.UpdateExisting( + (uint)Beatmap.Value.BeatmapSetInfo.OnlineID, + Beatmap.Value.BeatmapSetInfo.Beatmaps.Where(b => b.OnlineID > 0).Select(b => (uint)b.OnlineID).ToArray(), + (uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count(b => b.OnlineID <= 0), + settings) + : PutBeatmapSetRequest.CreateNew((uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, settings); + + createRequest.Success += async response => + { + createSetStep.SetCompleted(); + beatmapSetId = response.BeatmapSetId; + + // at this point the set has an assigned online ID. + // it's important to proactively store it to the realm database, + // so that in the event in further failures in the process, the online ID is not lost. + // losing it can incur creation of redundant new sets server-side, or even cause online ID confusion. + if (!beatmapHasOnlineId) + { + await realmAccess.WriteAsync(r => + { + var refetchedSet = r.Find(Beatmap.Value.BeatmapSetInfo.ID); + refetchedSet!.OnlineID = (int)beatmapSetId.Value; + }).ConfigureAwait(true); + } + + await createBeatmapPackage(response).ConfigureAwait(true); + }; + createRequest.Failure += ex => + { + createSetStep.SetFailed(ex.Message); + Logger.Log($"Beatmap set submission failed on creation: {ex}"); + allowExit(); + }; + + createSetStep.SetInProgress(); + api.Queue(createRequest); + } + + private async Task createBeatmapPackage(PutBeatmapSetResponse response) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + exportStep.SetInProgress(); + + try + { + beatmapPackageStream = new MemoryStream(); + exportProgressNotification = new ProgressNotification(); + + var legacyBeatmapExporter = new SubmissionBeatmapExporter(storage, response); + + await legacyBeatmapExporter + .ExportToStreamAsync(Beatmap.Value.BeatmapSetInfo.ToLive(realmAccess), beatmapPackageStream, exportProgressNotification) + .ConfigureAwait(true); + } + catch (Exception ex) + { + exportStep.SetFailed(ex.Message); + exportProgressNotification = null; + Logger.Log($"Beatmap set submission failed on export: {ex}"); + allowExit(); + return; + } + + exportStep.SetCompleted(); + exportProgressNotification = null; + + await Task.Delay(200).ConfigureAwait(true); + + if (response.Files.Count > 0) + await patchBeatmapSet(response.Files).ConfigureAwait(true); + else + replaceBeatmapSet(); + } + + private async Task patchBeatmapSet(ICollection onlineFiles) + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + var onlineFilesByFilename = onlineFiles.ToDictionary(f => f.Filename, f => f.SHA2Hash); + + // disposing the `ArchiveReader` makes the underlying stream no longer readable which we don't want. + // make a local copy to defend against it. + using var archiveReader = new ZipArchiveReader(new MemoryStream(beatmapPackageStream.ToArray())); + var filesToUpdate = new HashSet(); + + foreach (string filename in archiveReader.Filenames) + { + string localHash = archiveReader.GetStream(filename).ComputeSHA2Hash(); + + if (!onlineFilesByFilename.Remove(filename, out string? onlineHash)) + { + filesToUpdate.Add(filename); + continue; + } + + if (!localHash.Equals(onlineHash, StringComparison.OrdinalIgnoreCase)) + filesToUpdate.Add(filename); + } + + var changedFiles = new Dictionary(); + + foreach (string file in filesToUpdate) + changedFiles.Add(file, await archiveReader.GetStream(file).ReadAllBytesToArrayAsync().ConfigureAwait(true)); + + var patchRequest = new PatchBeatmapPackageRequest(beatmapSetId.Value); + patchRequest.FilesChanged.AddRange(changedFiles); + patchRequest.FilesDeleted.AddRange(onlineFilesByFilename.Keys); + patchRequest.Success += uploadCompleted; + patchRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on upload: {ex}"); + allowExit(); + }; + patchRequest.Progressed += (current, total) => uploadStep.SetInProgress(total > 0 ? (float)current / total : null); + + api.Queue(patchRequest); + uploadStep.SetInProgress(); + } + + private void replaceBeatmapSet() + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + var uploadRequest = new ReplaceBeatmapPackageRequest(beatmapSetId.Value, beatmapPackageStream.ToArray()); + + uploadRequest.Success += uploadCompleted; + uploadRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on upload: {ex}"); + allowExit(); + }; + uploadRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / Math.Max(total, 1)); + + api.Queue(uploadRequest); + uploadStep.SetInProgress(); + } + + private void uploadCompleted() + { + uploadStep.SetCompleted(); + updateLocalBeatmap().ConfigureAwait(true); + } + + private async Task updateLocalBeatmap() + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + updateStep.SetInProgress(); + await Task.Delay(200).ConfigureAwait(true); + + try + { + importedSet = await beatmaps.ImportAsUpdate( + updateProgressNotification = new ProgressNotification(), + new ImportTask(beatmapPackageStream, $"{beatmapSetId}.osz"), + Beatmap.Value.BeatmapSetInfo).ConfigureAwait(true); + } + catch (Exception ex) + { + updateStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on local update: {ex}"); + allowExit(); + return; + } + + updateStep.SetCompleted(); + showBeatmapCard(); + allowExit(); + + if (configManager.Get(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission)) + { + await Task.Delay(1000).ConfigureAwait(true); + game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetId}"); + } + } + + private void showBeatmapCard() + { + Debug.Assert(beatmapSetId != null); + + var getBeatmapSetRequest = new GetBeatmapSetRequest((int)beatmapSetId.Value); + getBeatmapSetRequest.Success += beatmapSet => + { + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + successContainer.Add(loaded); + flashLayer.FadeOutFromOne(2000, Easing.OutQuint); + }); + }; + + api.Queue(getBeatmapSetRequest); + } + + private void allowExit() + { + BackButtonVisibility.Value = true; + } + + protected override void Update() + { + base.Update(); + + if (exportProgressNotification != null && exportProgressNotification.Ongoing) + exportStep.SetInProgress(exportProgressNotification.Progress); + + if (updateProgressNotification != null && updateProgressNotification.Ongoing) + updateStep.SetInProgress(updateProgressNotification.Progress); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // We probably want a method of cancelling in the future… + if (!BackButtonVisibility.Value) + return true; + + if (importedSet != null) + { + game?.PerformFromScreen(s => + { + if (s is OsuScreen osuScreen) + { + Debug.Assert(importedSet != null); + var targetBeatmap = importedSet.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == Beatmap.Value.BeatmapInfo.DifficultyName) + ?? importedSet.Value.Beatmaps.First(); + osuScreen.Beatmap.Value = beatmaps.GetWorkingBeatmap(targetBeatmap); + } + + s.Push(new EditorLoader()); + }, [typeof(SongSelect)]); + + return false; + } + + return base.OnExiting(e); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + overlay.Show(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapPackageStream?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs new file mode 100644 index 0000000000..8cccc339a6 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Screens.Edit.Submission +{ + public class BeatmapSubmissionSettings + { + public Bindable Target { get; } = new Bindable(); + + public Bindable NotifyOnDiscussionReplies { get; } = new Bindable(); + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.cs b/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.cs new file mode 100644 index 0000000000..92a4ac4e4e --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.ContentPermissions))] + public partial class ScreenContentPermissions : WizardScreen + { + [BackgroundDependencyLoader] + private void load(OsuGame? game) + { + Content.AddRange(new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = BeatmapSubmissionStrings.ContentPermissionsDisclaimer, + }, + new RoundedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 450, + Text = BeatmapSubmissionStrings.CheckContentUsageGuidelines, + Action = () => game?.ShowWiki(@"Rules/Content_usage_permissions"), + }, + }); + } + + public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ContentPermissionsAcknowledgement; + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs b/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs new file mode 100644 index 0000000000..861c5051f4 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.FrequentlyAskedQuestions))] + public partial class ScreenFrequentlyAskedQuestions : WizardScreen + { + [BackgroundDependencyLoader] + private void load(OsuGame? game, IAPIProvider api) + { + Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.BeatmapRankingCriteriaDescription, + ButtonText = BeatmapSubmissionStrings.BeatmapRankingCriteria, + Action = () => game?.ShowWiki(@"Ranking_Criteria"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.SubmissionProcessDescription, + ButtonText = BeatmapSubmissionStrings.SubmissionProcess, + Action = () => game?.ShowWiki(@"Beatmap_ranking_procedure"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.MappingHelpForumDescription, + ButtonText = BeatmapSubmissionStrings.MappingHelpForum, + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/56"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.ModdingQueuesForumDescription, + ButtonText = BeatmapSubmissionStrings.ModdingQueuesForum, + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/60"), + }, + }, + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs new file mode 100644 index 0000000000..969105b5c6 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.SubmissionSettings))] + public partial class ScreenSubmissionSettings : WizardScreen + { + private readonly BindableBool notifyOnDiscussionReplies = new BindableBool(); + private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool(); + + public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager, OsuColour colours, BeatmapSubmissionSettings settings) + { + configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, settings.NotifyOnDiscussionReplies); + configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission); + + Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FormEnumDropdown + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption, + Current = settings.Target, + }, + new FormCheckBox + { + Caption = BeatmapSubmissionStrings.NotifyOnDiscussionReplies, + Current = settings.NotifyOnDiscussionReplies, + }, + new FormCheckBox + { + Caption = BeatmapSubmissionStrings.LoadInBrowserAfterSubmission, + Current = loadInBrowserAfterSubmission, + }, + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE, weight: FontWeight.Bold)) + { + RelativeSizeAxes = Axes.X, + Colour = colours.Orange1, + Text = BeatmapSubmissionStrings.LegacyExportDisclaimer, + Padding = new MarginPadding { Top = 20 } + }, + } + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs new file mode 100644 index 0000000000..fab080cdba --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Edit.Submission +{ + public class SubmissionBeatmapExporter : LegacyBeatmapExporter + { + private readonly uint? beatmapSetId; + private readonly HashSet? allocatedBeatmapIds; + + public SubmissionBeatmapExporter(Storage storage, PutBeatmapSetResponse putBeatmapSetResponse) + : base(storage) + { + beatmapSetId = putBeatmapSetResponse.BeatmapSetId; + allocatedBeatmapIds = putBeatmapSetResponse.BeatmapIds.Select(id => (int)id).ToHashSet(); + } + + protected override void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { + base.MutateBeatmap(beatmapSet, playableBeatmap); + + if (beatmapSetId != null && allocatedBeatmapIds != null) + { + playableBeatmap.BeatmapInfo.BeatmapSet = beatmapSet; + playableBeatmap.BeatmapInfo.BeatmapSet!.OnlineID = (int)beatmapSetId; + + if (allocatedBeatmapIds.Contains(playableBeatmap.BeatmapInfo.OnlineID)) + { + allocatedBeatmapIds.Remove(playableBeatmap.BeatmapInfo.OnlineID); + return; + } + + if (playableBeatmap.BeatmapInfo.OnlineID > 0) + throw new InvalidOperationException(@"Encountered beatmap with ID that has not been assigned to it by the server!"); + + if (allocatedBeatmapIds.Count == 0) + throw new InvalidOperationException(@"Ran out of new beatmap IDs to assign to unsubmitted beatmaps!"); + + int newId = allocatedBeatmapIds.First(); + allocatedBeatmapIds.Remove(newId); + playableBeatmap.BeatmapInfo.OnlineID = newId; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs new file mode 100644 index 0000000000..101313c627 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -0,0 +1,212 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class SubmissionStageProgress : CompositeDrawable + { + public LocalisableString StageDescription { get; init; } + + private Bindable status { get; } = new Bindable(); + + private Bindable progress { get; } = new Bindable(); + + private Container progressBarContainer = null!; + private Box progressBar = null!; + private Container iconContainer = null!; + private OsuTextFlowContainer errorMessage = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = StageDescription, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + iconContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Children = + [ + progressBarContainer = new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Width = 150, + Height = 10, + CornerRadius = 5, + Masking = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + progressBar = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 0, + Colour = colourProvider.Highlight1, + } + } + }, + errorMessage = new OsuTextFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // should really be `CentreRight` too, but that's broken due to a framework bug + // (https://github.com/ppy/osu-framework/issues/5084) + TextAnchor = Anchor.BottomRight, + Width = 450, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Colour = colours.Red1, + } + ] + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); + progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); + } + + public void SetNotStarted() => status.Value = StageStatusType.NotStarted; + + public void SetInProgress(float? progress = null) + { + this.progress.Value = progress; + status.Value = StageStatusType.InProgress; + } + + public void SetCompleted() => status.Value = StageStatusType.Completed; + + public void SetFailed(string reason) + { + status.Value = StageStatusType.Failed; + errorMessage.Text = reason; + } + + public void SetCanceled() => status.Value = StageStatusType.Canceled; + + private const float transition_duration = 200; + + private void updateProgress() + { + if (progress.Value != null) + progressBar.ResizeWidthTo(progress.Value.Value, transition_duration, Easing.OutQuint); + + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint); + } + + private void updateStatus() + { + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint); + errorMessage.FadeTo(status.Value == StageStatusType.Failed ? 1 : 0, transition_duration, Easing.OutQuint); + + iconContainer.Clear(); + iconContainer.ClearTransforms(); + + switch (status.Value) + { + case StageStatusType.InProgress: + iconContainer.Child = new LoadingSpinner + { + Size = new Vector2(16), + State = { Value = Visibility.Visible, }, + }; + iconContainer.Colour = colours.Orange1; + break; + + case StageStatusType.Completed: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.CheckCircle, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Green1; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + break; + + case StageStatusType.Failed: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.ExclamationCircle, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Red1; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + break; + + case StageStatusType.Canceled: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.Ban, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Gray8; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + break; + } + } + + public enum StageStatusType + { + NotStarted, + InProgress, + Completed, + Failed, + Canceled, + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs index 49e5b76dd6..86d8ac681f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -20,6 +20,8 @@ namespace osu.Game.Screens.Edit.Timing { public partial class ControlPointList : CompositeDrawable { + public Action? SelectClosestTimingPoint { get; init; } + private ControlPointTable table = null!; private Container controls = null!; private OsuButton deleteButton = null!; @@ -34,6 +36,9 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private Bindable selectedGroup { get; set; } = null!; + [Resolved] + private IEditorChangeHandler? editorChangeHandler { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours, OverlayColourProvider colourProvider) { @@ -72,7 +77,7 @@ namespace osu.Game.Screens.Edit.Timing new RoundedButton { Text = "Select closest to current time", - Action = goToCurrentGroup, + Action = SelectClosestTimingPoint, Size = new Vector2(220, 30), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -110,6 +115,9 @@ namespace osu.Game.Screens.Edit.Timing } }, }; + + if (editorChangeHandler != null) + editorChangeHandler.OnStateChange += onUndoRedo; } protected override void LoadComplete() @@ -140,17 +148,6 @@ namespace osu.Game.Screens.Edit.Timing table.Padding = new MarginPadding { Bottom = controls.DrawHeight }; } - private void goToCurrentGroup() - { - double accurateTime = clock.CurrentTimeAccurate; - - var activeTimingPoint = Beatmap.ControlPointInfo.TimingPointAt(accurateTime); - var activeEffectPoint = Beatmap.ControlPointInfo.EffectPointAt(accurateTime); - - double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time); - selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(latestActiveTime); - } - private void delete() { if (selectedGroup.Value == null) @@ -185,5 +182,21 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.Value = group; } + + private void onUndoRedo() + { + // Best effort. We have no tracking of control points through undo/redo changes. + // If we don't deselect, things like offset changes could spawn groups to be added from previous states (see https://github.com/ppy/osu/issues/31098). + if (selectedGroup.Value != null && !Beatmap.ControlPointInfo.Groups.Contains(selectedGroup.Value)) + selectedGroup.Value = null; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorChangeHandler != null) + editorChangeHandler.OnStateChange -= onUndoRedo; + } } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index fd812cfe2b..a37674b104 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -21,6 +22,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Screens.Edit.Timing.RowAttributes; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit.Timing { @@ -177,7 +179,7 @@ namespace osu.Game.Screens.Edit.Timing private readonly BindableWithCurrent current = new BindableWithCurrent(); private Box background = null!; - private Box currentIndicator = null!; + private Drawable currentIndicator = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -202,7 +204,7 @@ namespace osu.Game.Screens.Edit.Timing { RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + InternalChildren = new[] { background = new Box { @@ -210,11 +212,26 @@ namespace osu.Game.Screens.Edit.Timing Colour = colourProvider.Background1, Alpha = 0, }, - currentIndicator = new Box + currentIndicator = new Container { - RelativeSizeAxes = Axes.Y, - Width = 5, + RelativeSizeAxes = Axes.Both, Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Y, + Width = 5, + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Blending = BlendingParameters.Additive, + X = 5, + Width = 150, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.1f), Color4.White.Opacity(0)) + }, + } }, new Container { @@ -281,14 +298,8 @@ namespace osu.Game.Screens.Edit.Timing bool hasCurrentTimingPoint = activeTimingPoint.Value != null && current.Value.ControlPoints.Contains(activeTimingPoint.Value); bool hasCurrentEffectPoint = activeEffectPoint.Value != null && current.Value.ControlPoints.Contains(activeEffectPoint.Value); - if (IsHovered || isSelected) - background.FadeIn(100, Easing.OutQuint); - else if (hasCurrentTimingPoint || hasCurrentEffectPoint) - background.FadeTo(0.2f, 100, Easing.OutQuint); - else - background.FadeOut(100, Easing.OutQuint); - - background.Colour = isSelected ? colourProvider.Colour3 : colourProvider.Background1; + background.FadeTo(IsHovered || isSelected ? 1 : 0, 100, Easing.OutQuint); + background.FadeColour(isSelected ? colourProvider.Colour3 : colourProvider.Background1, 100, Easing.OutQuint); if (hasCurrentTimingPoint || hasCurrentEffectPoint) { diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index 29e730c865..f3bd9ff257 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -12,13 +12,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Utils; using osuTK; namespace osu.Game.Screens.Edit.Timing @@ -27,7 +29,7 @@ namespace osu.Game.Screens.Edit.Timing { private Container swing = null!; - private OsuSpriteText bpmText = null!; + private OsuTextFlowContainer bpmText = null!; private Drawable weight = null!; private Drawable stick = null!; @@ -41,6 +43,9 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private OverlayColourProvider overlayColourProvider { get; set; } = null!; + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } = null!; + public bool EnableClicking { get => metronomeTick.EnableClicking; @@ -209,10 +214,15 @@ namespace osu.Game.Screens.Edit.Timing }, } }, - bpmText = new OsuSpriteText + bpmText = new OsuTextFlowContainer(st => + { + st.Font = OsuFont.Default.With(fixedWidth: true); + st.Spacing = new Vector2(-2.2f, 0); + }) { Name = @"BPM display", Colour = overlayColourProvider.Content1, + AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Y = -3, @@ -222,21 +232,56 @@ namespace osu.Game.Screens.Edit.Timing Clock = new FramedClock(metronomeClock = new StopwatchClock(true)); } - private double beatLength; + private double effectiveBeatLength; + + private double effectiveBpm => 60_000 / effectiveBeatLength; private TimingControlPoint timingPoint = null!; private bool isSwinging; - private readonly BindableInt interpolatedBpm = new BindableInt(); + private readonly BindableDouble interpolatedBpm = new BindableDouble(); private ScheduledDelegate? latchDelegate; + private bool spedUp; + + private int computeSpedUpDivisor() + { + if (!spedUp) + return 1; + + if (beatDivisor.Value % 3 == 0) + return 3; + if (beatDivisor.Value % 2 == 0) + return 2; + + return 1; + } + protected override void LoadComplete() { base.LoadComplete(); - interpolatedBpm.BindValueChanged(bpm => bpmText.Text = bpm.NewValue.ToLocalisableString()); + interpolatedBpm.BindValueChanged(_ => updateBpmText()); + } + + private void updateBpmText() + { + int intPart = (int)interpolatedBpm.Value; + + bpmText.Text = intPart.ToLocalisableString(); + + // While interpolating between two integer values, showing the decimal places would look a bit odd + // so rounding is applied until we're close to the final value. + int decimalPlaces = FormatUtils.FindPrecision((decimal)effectiveBpm); + + if (decimalPlaces > 0) + { + bool reachedFinalNumber = intPart == (int)effectiveBpm; + + bpmText.AddText((effectiveBpm % 1).ToLocalisableString("." + new string('0', decimalPlaces)), cp => cp.Alpha = reachedFinalNumber ? 0.5f : 0.1f); + } } protected override void Update() @@ -250,16 +295,19 @@ namespace osu.Game.Screens.Edit.Timing timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(BeatSyncSource.Clock.CurrentTime); - if (beatLength != timingPoint.BeatLength) + Divisor = metronomeTick.Divisor = computeSpedUpDivisor(); + + if (effectiveBeatLength != timingPoint.BeatLength / Divisor) { - beatLength = timingPoint.BeatLength; + effectiveBeatLength = timingPoint.BeatLength / Divisor; EarlyActivationMilliseconds = timingPoint.BeatLength / 2; - float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((timingPoint.BPM - 30) / 480, 0, 1)); + float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((effectiveBpm - 30) / 480, 0, 1)); weight.MoveToY((float)Interpolation.Lerp(0.1f, 0.83f, bpmRatio), 600, Easing.OutQuint); - this.TransformBindableTo(interpolatedBpm, (int)Math.Round(timingPoint.BPM), 600, Easing.OutQuint); + + this.TransformBindableTo(interpolatedBpm, effectiveBpm, 300, Easing.OutExpo); } if (!BeatSyncSource.Clock.IsRunning && isSwinging) @@ -305,7 +353,7 @@ namespace osu.Game.Screens.Edit.Timing float currentAngle = swing.Rotation; float targetAngle = currentAngle > 0 ? -angle : angle; - swing.RotateTo(targetAngle, beatLength, Easing.InOutQuad); + swing.RotateTo(targetAngle, effectiveBeatLength, Easing.InOutQuad); } private void onTickPlayed() @@ -313,9 +361,25 @@ namespace osu.Game.Screens.Edit.Timing // Originally, this flash only occurred when the pendulum correctly passess the centre. // Mappers weren't happy with the metronome tick not playing immediately after starting playback // so now this matches the actual tick sample. - stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); + stick.FlashColour(overlayColourProvider.Content1, effectiveBeatLength, Easing.OutQuint); } + protected override bool OnKeyDown(KeyDownEvent e) + { + updateDivisorFromKey(e); + + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + updateDivisorFromKey(e); + } + + private void updateDivisorFromKey(UIEvent e) => spedUp = e.ControlPressed; + private partial class MetronomeTick : BeatSyncedContainer { public bool EnableClicking; diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index 67d4429be8..e7bf798298 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Edit.Timing [Cached] public readonly Bindable SelectedGroup = new Bindable(); + private readonly Bindable currentEditorMode = new Bindable(); + [Resolved] private EditorClock? editorClock { get; set; } @@ -35,23 +38,53 @@ namespace osu.Game.Screens.Edit.Timing { new Drawable[] { - new ControlPointList(), + new ControlPointList + { + SelectClosestTimingPoint = selectClosestTimingPoint, + }, new ControlPointSettings(), }, } }; + [BackgroundDependencyLoader] + private void load(Editor? editor) + { + if (editor != null) + currentEditorMode.BindTo(editor.Mode); + } + protected override void LoadComplete() { base.LoadComplete(); - if (editorClock != null) + // When entering the timing screen, let's choose the closest valid timing point. + // This will emulate the osu-stable behaviour where a metronome and timing information + // are presented on entering the screen. + currentEditorMode.BindValueChanged(mode => { - // When entering the timing screen, let's choose the closest valid timing point. - // This will emulate the osu-stable behaviour where a metronome and timing information - // are presented on entering the screen. - var nearestTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime); - SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(nearestTimingPoint.Time); + if (mode.NewValue == EditorScreenMode.Timing) + selectClosestTimingPoint(); + }); + selectClosestTimingPoint(); + } + + private void selectClosestTimingPoint() + { + if (editorClock == null) + return; + + double accurateTime = editorClock.CurrentTimeAccurate; + + var activeTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(accurateTime); + var activeEffectPoint = EditorBeatmap.ControlPointInfo.EffectPointAt(accurateTime); + + if (activeEffectPoint.Equals(EffectControlPoint.DEFAULT)) + SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(activeTimingPoint.Time); + else + { + double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time); + SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(latestActiveTime); } } diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index 45213b7bdb..2df2dd7c5b 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -4,8 +4,8 @@ using System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; @@ -305,7 +305,8 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private IBindable beatmap { get; set; } = null!; - private readonly IBindable track = new Bindable(); + [Resolved] + private EditorClock editorClock { get; set; } = null!; public WaveformRow(bool isMainRow) { @@ -313,7 +314,7 @@ namespace osu.Game.Screens.Edit.Timing } [BackgroundDependencyLoader] - private void load(EditorClock clock) + private void load() { InternalChildren = new Drawable[] { @@ -343,13 +344,16 @@ namespace osu.Game.Screens.Edit.Timing Colour = colourProvider.Content2 } }; - - track.BindTo(clock.Track); } protected override void LoadComplete() { - track.ValueChanged += _ => waveformGraph.Waveform = beatmap.Value.Waveform; + editorClock.TrackChanged += updateWaveform; + } + + private void updateWaveform() + { + waveformGraph.Waveform = beatmap.Value.Waveform; } public int BeatIndex { set => beatIndexText.Text = value.ToString(); } @@ -363,6 +367,14 @@ namespace osu.Game.Screens.Edit.Timing get => waveformGraph.X; set => waveformGraph.X = value; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateWaveform; + } } } } diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index ea32507ca0..f75250a832 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -33,7 +33,8 @@ namespace osu.Game.Screens.Footer private Box background = null!; private FillFlowContainer buttonsFlow = null!; - private Container removedButtonsContainer = null!; + private Container footerContentContainer = null!; + private Container hiddenButtonsContainer = null!; private LogoTrackingContainer logoTrackingContainer = null!; [Cached] @@ -71,15 +72,35 @@ namespace osu.Game.Screens.Footer RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background5 }, - buttonsFlow = new FillFlowContainer + new GridContainer { - Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, - Y = 10f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7, 0), - AutoSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + buttonsFlow = new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Y = 10f, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + AutoSizeAxes = Axes.Both, + }, + footerContentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Y = -15f, + }, + }, + } }, BackButton = new ScreenBackButton { @@ -88,7 +109,7 @@ namespace osu.Game.Screens.Footer Origin = Anchor.BottomLeft, Action = onBackPressed, }, - removedButtonsContainer = new Container + hiddenButtonsContainer = new Container { Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, Y = 10f, @@ -153,7 +174,7 @@ namespace osu.Game.Screens.Footer var oldButton = oldButtons[i]; buttonsFlow.Remove(oldButton, false); - removedButtonsContainer.Add(oldButton); + hiddenButtonsContainer.Add(oldButton); if (buttons.Count > 0) makeButtonDisappearToRight(oldButton, i, oldButtons.Length, true); @@ -188,7 +209,7 @@ namespace osu.Game.Screens.Footer } private ShearedOverlayContainer? activeOverlay; - private Container? contentContainer; + private VisibilityContainer? activeFooterContent; private readonly List temporarilyHiddenButtons = new List(); @@ -210,33 +231,28 @@ namespace osu.Game.Screens.Footer ? buttonsFlow.SkipWhile(b => b != targetButton).Skip(1) : buttonsFlow); - for (int i = 0; i < temporarilyHiddenButtons.Count; i++) - makeButtonDisappearToBottom(temporarilyHiddenButtons[i], 0, 0, false); + for (int i = temporarilyHiddenButtons.Count - 1; i >= 0; i--) + { + var button = temporarilyHiddenButtons[i]; + buttonsFlow.Remove(button, false); + hiddenButtonsContainer.Add(button); - var fallbackPosition = buttonsFlow.Any() - ? buttonsFlow.ToSpaceOfOtherDrawable(Vector2.Zero, this) - : BackButton.ToSpaceOfOtherDrawable(BackButton.LayoutRectangle.TopRight + new Vector2(5f, 0f), this); - - var targetPosition = targetButton?.ToSpaceOfOtherDrawable(targetButton.LayoutRectangle.TopRight, this) ?? fallbackPosition; + makeButtonDisappearToBottom(button, 0, 0, false); + } updateColourScheme(overlay.ColourProvider.Hue); footerContent = overlay.CreateFooterContent(); + activeFooterContent = footerContent; + var content = footerContent; - var content = footerContent ?? Empty(); - - Add(contentContainer = new Container - { - Y = -15f, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = targetPosition.X }, - Child = content, - }); + if (content != null) + footerContentContainer.Child = content; if (temporarilyHiddenButtons.Count > 0) - this.Delay(60).Schedule(() => content.Show()); + this.Delay(60).Schedule(() => content?.Show()); else - content.Show(); + content?.Show(); return new InvokeOnDisposal(clearActiveOverlayContainer); } @@ -246,20 +262,26 @@ namespace osu.Game.Screens.Footer if (activeOverlay == null) return; - Debug.Assert(contentContainer != null); - contentContainer.Child.Hide(); + Debug.Assert(activeFooterContent != null); + activeFooterContent.Hide(); - double timeUntilRun = contentContainer.Child.LatestTransformEndTime - Time.Current; + double timeUntilRun = activeFooterContent.LatestTransformEndTime - Time.Current; for (int i = 0; i < temporarilyHiddenButtons.Count; i++) - makeButtonAppearFromBottom(temporarilyHiddenButtons[i], 0); + { + var button = temporarilyHiddenButtons[i]; + hiddenButtonsContainer.Remove(button, false); + buttonsFlow.Add(button); + + makeButtonAppearFromBottom(button, 0); + } temporarilyHiddenButtons.Clear(); updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); - contentContainer.Delay(timeUntilRun).Expire(); - contentContainer = null; + activeFooterContent.Delay(timeUntilRun).Expire(); + activeFooterContent = null; activeOverlay = null; } diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index b80c1f87a4..0dfea463ac 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Screens.Footer; @@ -21,15 +22,21 @@ namespace osu.Game.Screens bool DisallowExternalBeatmapRulesetChanges { get; } /// - /// Whether the user can exit this by pressing the back button. + /// Whether the user can exit this . /// - bool AllowBackButton { get; } + /// + /// When overriden to false, + /// the user is blocked from exiting the screen via the action, + /// and the back button is hidden from this screen by the initial state of being set to hidden. + /// + bool AllowUserExit { get; } /// /// Whether a footer (and a back button) should be displayed underneath the screen. /// /// - /// Temporarily, the back button is shown regardless of whether is true. + /// Temporarily, the footer's own back button is shown regardless of whether is set to hidden. + /// This will be corrected as the footer becomes used more commonly. /// bool ShowFooter { get; } @@ -54,15 +61,31 @@ namespace osu.Game.Screens /// bool HideMenuCursorOnNonMouseInput { get; } + /// + /// On mobile phones, this specifies whether this requires the device to be in portrait orientation. + /// Tablet devices are unaffected by this property. + /// + /// + /// By default, all screens in the game display in landscape orientation on phones. + /// Setting this to true will display this screen in portrait orientation instead, + /// and switch back to landscape when transitioning back to a regular non-portrait screen. + /// + bool RequiresPortraitOrientation { get; } + /// /// Whether overlays should be able to be opened when this screen is current. /// IBindable OverlayActivationMode { get; } + /// + /// Whether the back button should be displayed in this screen. + /// + IBindable BackButtonVisibility { get; } + /// /// The current for this screen. /// - IBindable Activity { get; } + Bindable Activity { get; } /// /// The amount of parallax to be applied while this screen is displayed. diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index d71ee05b27..9e7ff80f7c 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shaders; @@ -15,6 +16,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Seasonal; using IntroSequence = osu.Game.Configuration.IntroSequence; namespace osu.Game.Screens @@ -37,6 +39,11 @@ namespace osu.Game.Screens private IntroScreen getIntroSequence() { + // Headless tests run too fast to load non-circles intros correctly. + // They will hit the "audio can't play" notification and cause random test failures. + if (SeasonalUIConfig.ENABLED && !DebugUtils.IsNUnitRunning) + return new IntroChristmas(createMainMenu); + if (introSequence == IntroSequence.Random) introSequence = (IntroSequence)RNG.Next(0, (int)IntroSequence.Random); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 41920605b0..25fa689d4c 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -245,6 +245,15 @@ namespace osu.Game.Screens.Menu if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed) return false; + if (e.Key >= Key.F1 && e.Key <= Key.F35) + return false; + + switch (e.Key) + { + case Key.Escape: + return false; + } + if (triggerInitialOsuLogo()) return true; diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index 0dc54b321f..7b23cc7538 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -20,10 +20,12 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Skinning; @@ -169,11 +171,20 @@ namespace osu.Game.Screens.Menu if (s.Beatmaps.Count == 0) return; - initialBeatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps.First()); + var working = beatmaps.GetWorkingBeatmap(s.Beatmaps.First()); + + // Ensure files area actually present on disk. + // This is to handle edge cases like users deleting files outside the game and breaking the world. + if (!hasAllFiles(working)) + return; + + initialBeatmap = working; }); return UsingThemedIntro = initialBeatmap != null; } + + AddInternal(new GlobalScrollAdjustsVolume()); } public override void OnEntering(ScreenTransitionEvent e) @@ -185,6 +196,20 @@ namespace osu.Game.Screens.Menu [Resolved] private INotificationOverlay notifications { get; set; } + private bool hasAllFiles(WorkingBeatmap working) + { + foreach (var f in working.BeatmapSetInfo.Files) + { + using (var str = working.GetStream(f.File.GetStoragePath())) + { + if (str == null) + return false; + } + } + + return true; + } + private void ensureEventuallyArrivingAtMenu() { // This intends to handle the case where an intro may get stuck. @@ -207,7 +232,7 @@ namespace osu.Game.Screens.Menu Text = NotificationsStrings.AudioPlaybackIssue }); } - }, 5000); + }, 8000); } public override void OnResuming(ScreenTransitionEvent e) diff --git a/osu.Game/Screens/Menu/KiaiMenuFountains.cs b/osu.Game/Screens/Menu/KiaiMenuFountains.cs index 07c06dcdb9..6e0351f922 100644 --- a/osu.Game/Screens/Menu/KiaiMenuFountains.cs +++ b/osu.Game/Screens/Menu/KiaiMenuFountains.cs @@ -3,10 +3,9 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Framework.Utils; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Menu @@ -16,12 +15,17 @@ namespace osu.Game.Screens.Menu private StarFountain leftFountain = null!; private StarFountain rightFountain = null!; + [Resolved] + private GameHost host { get; set; } = null!; + + private StarFountainSounds sounds = null!; + [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.Both; - Children = new[] + Children = new Drawable[] { leftFountain = new StarFountain { @@ -35,32 +39,28 @@ namespace osu.Game.Screens.Menu Origin = Anchor.BottomRight, X = -250, }, + sounds = new StarFountainSounds() }; } private bool isTriggered; - private double? lastTrigger; - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + protected override void Update() { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + base.Update(); - if (effectPoint.KiaiMode && !isTriggered) + if (EffectPoint.KiaiMode && !isTriggered) { - bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; + bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - EffectPoint.Time) < 500; if (isNearEffectPoint) Shoot(); } - isTriggered = effectPoint.KiaiMode; + isTriggered = EffectPoint.KiaiMode; } public void Shoot() { - if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500) - return; - int direction = RNG.Next(-1, 2); switch (direction) @@ -81,7 +81,9 @@ namespace osu.Game.Screens.Menu break; } - lastTrigger = Clock.CurrentTime; + // Don't play SFX when game is in background, as it can be a bit noisy. + if (host.IsActive.Value) + sounds.Play(); } } } diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 6d9d2f69b7..53d153ab31 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -106,9 +106,12 @@ namespace osu.Game.Screens.Menu foreach (var source in amplitudeSources) addAmplitudesFromSource(source); + float kiaiMultiplier = beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f; + for (int i = 0; i < bars_per_visualiser; i++) { - float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f); + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * kiaiMultiplier; + if (targetAmplitude > frequencyAmplitudes[i]) frequencyAmplitudes[i] = targetAmplitude; } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index c753a52657..135b3dba17 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -13,11 +13,13 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -28,6 +30,7 @@ using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; @@ -35,8 +38,10 @@ using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; +using osu.Game.Seasonal; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Menu { @@ -48,7 +53,7 @@ namespace osu.Game.Screens.Menu public override bool HideOverlaysOnEnter => Buttons == null || Buttons.State == ButtonSystemState.Initial; - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool AllowExternalScreenChange => true; @@ -85,6 +90,7 @@ namespace osu.Game.Screens.Menu private Bindable holdDelay; private Bindable loginDisplayed; + private Bindable showMobileDisclaimer; private HoldToExitGameOverlay holdToExitGameOverlay; @@ -109,6 +115,7 @@ namespace osu.Game.Screens.Menu { holdDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); loginDisplayed = statics.GetBindable(Static.LoginOverlayDisplayed); + showMobileDisclaimer = config.GetBindable(OsuSetting.ShowMobileDisclaimer); if (host.CanExit) { @@ -124,6 +131,8 @@ namespace osu.Game.Screens.Menu AddRangeInternal(new[] { + SeasonalUIConfig.ENABLED ? new MainMenuSeasonalLighting() : Empty(), + new GlobalScrollAdjustsVolume(), buttonsContainer = new ParallaxContainer { ParallaxAmount = 0.01f, @@ -159,14 +168,15 @@ namespace osu.Game.Screens.Menu } }, logoTarget = new Container { RelativeSizeAxes = Axes.Both, }, - sideFlashes = new MenuSideFlashes(), + sideFlashes = SeasonalUIConfig.ENABLED ? new SeasonalMenuSideFlashes() : new MenuSideFlashes(), songTicker = new SongTicker { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Margin = new MarginPadding { Right = 15, Top = 5 } }, - new KiaiMenuFountains(), + // For now, this is too much alongside the seasonal lighting. + SeasonalUIConfig.ENABLED ? Empty() : new KiaiMenuFountains(), bottomElementsFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -197,18 +207,20 @@ namespace osu.Game.Screens.Menu holdToExitGameOverlay?.CreateProxy() ?? Empty() }); + float baseDim = SeasonalUIConfig.ENABLED ? 0.84f : 1; + Buttons.StateChanged += state => { switch (state) { case ButtonSystemState.Initial: case ButtonSystemState.Exit: - ApplyToBackground(b => b.FadeColour(Color4.White, 500, Easing.OutSine)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(baseDim), 500, Easing.OutSine)); onlineMenuBanner.State.Value = Visibility.Hidden; break; default: - ApplyToBackground(b => b.FadeColour(OsuColour.Gray(0.8f), 500, Easing.OutSine)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(baseDim * 0.8f), 500, Easing.OutSine)); onlineMenuBanner.State.Value = Visibility.Visible; break; } @@ -248,6 +260,9 @@ namespace osu.Game.Screens.Menu [CanBeNull] private Drawable proxiedLogo; + [CanBeNull] + private ScheduledDelegate mobileDisclaimerSchedule; + protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); @@ -268,26 +283,46 @@ namespace osu.Game.Screens.Menu sideFlashes.Delay(FADE_IN_DURATION).FadeIn(64, Easing.InQuint); } - else if (!api.IsLoggedIn || api.State.Value == APIState.RequiresSecondFactorAuth) + else { // copy out old action to avoid accidentally capturing logo.Action in closure, causing a self-reference loop. var previousAction = logo.Action; - // we want to hook into logo.Action to display the login overlay, but also preserve the return value of the old action. + // we want to hook into logo.Action to display certain overlays, but also preserve the return value of the old action. // therefore pass the old action to displayLogin, so that it can return that value. // this ensures that the OsuLogo sample does not play when it is not desired. - logo.Action = () => displayLogin(previousAction); + logo.Action = () => onLogoClick(previousAction); } + } - bool displayLogin(Func originalAction) + private bool onLogoClick(Func originalAction) + { + if (showMobileDisclaimer.Value) { - if (!loginDisplayed.Value) + mobileDisclaimerSchedule?.Cancel(); + mobileDisclaimerSchedule = Scheduler.AddDelayed(() => { - Scheduler.AddDelayed(() => login?.Show(), 500); - loginDisplayed.Value = true; - } + dialogOverlay.Push(new MobileDisclaimerDialog(() => + { + showMobileDisclaimer.Value = false; + displayLoginIfApplicable(); + })); + }, 500); + } + else + displayLoginIfApplicable(); - return originalAction.Invoke(); + return originalAction.Invoke(); + } + + private void displayLoginIfApplicable() + { + if (loginDisplayed.Value) return; + + if (!api.IsLoggedIn || api.State.Value == APIState.RequiresSecondFactorAuth) + { + Scheduler.AddDelayed(() => login?.Show(), 500); + loginDisplayed.Value = true; } } @@ -436,5 +471,25 @@ namespace osu.Game.Screens.Menu public void OnReleased(KeyBindingReleaseEvent e) { } + + private partial class MobileDisclaimerDialog : PopupDialog + { + public MobileDisclaimerDialog(Action confirmed) + { + HeaderText = ButtonSystemStrings.MobileDisclaimerHeader; + BodyText = ButtonSystemStrings.MobileDisclaimerBody; + + Icon = FontAwesome.Solid.SmileBeam; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Understood", + Action = confirmed, + }, + }; + } + } } } diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs index f4e992be9a..f152c0c93c 100644 --- a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs @@ -1,21 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osuTK.Graphics; -using osu.Game.Skinning; -using osu.Game.Online.API; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { - internal partial class MenuLogoVisualisation : LogoVisualisation + public partial class MenuLogoVisualisation : LogoVisualisation { - private IBindable user; - private Bindable skin; + private IBindable user = null!; + private Bindable skin = null!; [BackgroundDependencyLoader] private void load(IAPIProvider api, SkinManager skinManager) @@ -23,11 +21,11 @@ namespace osu.Game.Screens.Menu user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); + user.ValueChanged += _ => UpdateColour(); + skin.BindValueChanged(_ => UpdateColour(), true); } - private void updateColour() + protected virtual void UpdateColour() { if (user.Value?.IsSupporter ?? false) Colour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? Color4.White; diff --git a/osu.Game/Screens/Menu/MenuSideFlashes.cs b/osu.Game/Screens/Menu/MenuSideFlashes.cs index 533c39826c..426896825e 100644 --- a/osu.Game/Screens/Menu/MenuSideFlashes.cs +++ b/osu.Game/Screens/Menu/MenuSideFlashes.cs @@ -3,8 +3,10 @@ #nullable disable -using osuTK.Graphics; +using System; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -13,17 +15,19 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Skinning; using osu.Game.Online.API; -using System; -using osu.Framework.Audio.Track; -using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { public partial class MenuSideFlashes : BeatSyncedContainer { + protected virtual bool RefreshColoursEveryFlash => false; + + protected virtual float Intensity => 2; + private readonly IBindable beatmap = new Bindable(); private Box leftBox; @@ -67,7 +71,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Y, - Width = box_width * 2, + Width = box_width * Intensity, Height = 1.5f, // align off-screen to make sure our edges don't become visible during parallax. X = -box_width, @@ -79,7 +83,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.Y, - Width = box_width * 2, + Width = box_width * Intensity, Height = 1.5f, X = box_width, Alpha = 0, @@ -87,8 +91,11 @@ namespace osu.Game.Screens.Menu } }; - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); + if (!RefreshColoursEveryFlash) + { + user.ValueChanged += _ => updateColour(); + skin.BindValueChanged(_ => updateColour(), true); + } } protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) @@ -104,18 +111,28 @@ namespace osu.Game.Screens.Menu private void flash(Drawable d, double beatLength, bool kiai, ChannelAmplitudes amplitudes) { - d.FadeTo(Math.Max(0, ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier)), box_fade_in_time) + if (RefreshColoursEveryFlash) + updateColour(); + + d.FadeTo(Math.Clamp(0.1f + ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier), 0.1f, 1), + box_fade_in_time) .Then() .FadeOut(beatLength, Easing.In); } - private void updateColour() + protected virtual Color4 GetBaseColour() { Color4 baseColour = colours.Blue; if (user.Value?.IsSupporter ?? false) baseColour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? baseColour; + return baseColour; + } + + private void updateColour() + { + var baseColour = GetBaseColour(); // linear colour looks better in this case, so let's use it for now. Color4 gradientDark = baseColour.Opacity(0).ToLinear(); Color4 gradientLight = baseColour.Opacity(0.6f).ToLinear(); diff --git a/osu.Game/Screens/Menu/MenuTip.cs b/osu.Game/Screens/Menu/MenuTip.cs index 3fc5fe57fb..af7cfde52b 100644 --- a/osu.Game/Screens/Menu/MenuTip.cs +++ b/osu.Game/Screens/Menu/MenuTip.cs @@ -122,7 +122,8 @@ namespace osu.Game.Screens.Menu MenuTipStrings.RandomSkinShortcut, MenuTipStrings.ToggleReplaySettingsShortcut, MenuTipStrings.CopyModsFromScore, - MenuTipStrings.AutoplayBeatmapShortcut + MenuTipStrings.AutoplayBeatmapShortcut, + MenuTipStrings.LazerIsNotAWord }; return tips[RNG.Next(0, tips.Length)]; diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index f2e2e25fa6..31f47c1349 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -53,8 +53,12 @@ namespace osu.Game.Screens.Menu private Sample sampleClick; private SampleChannel sampleClickChannel; - private Sample sampleBeat; - private Sample sampleDownbeat; + protected virtual MenuLogoVisualisation CreateMenuLogoVisualisation() => new MenuLogoVisualisation(); + + protected virtual double BeatSampleVariance => 0.1; + + protected Sample SampleBeat; + protected Sample SampleDownbeat; private readonly Container colourAndTriangles; private readonly TrianglesV2 triangles; @@ -151,15 +155,15 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both, Children = new Drawable[] { - visualizer = new MenuLogoVisualisation + visualizer = CreateMenuLogoVisualisation().With(v => { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Alpha = visualizer_default_alpha, - Size = SCALE_ADJUST - }, - new Container + v.RelativeSizeAxes = Axes.Both; + v.Origin = Anchor.Centre; + v.Anchor = Anchor.Centre; + v.Alpha = visualizer_default_alpha; + v.Size = SCALE_ADJUST; + }), + LogoElements = new Container { AutoSizeAxes = Axes.Both, Children = new Drawable[] @@ -243,6 +247,8 @@ namespace osu.Game.Screens.Menu }; } + public Container LogoElements { get; private set; } + /// /// Schedule a new external animation. Handled queueing and finishing previous animations in a sane way. /// @@ -271,8 +277,9 @@ namespace osu.Game.Screens.Menu private void load(TextureStore textures, AudioManager audio) { sampleClick = audio.Samples.Get(@"Menu/osu-logo-select"); - sampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat"); - sampleDownbeat = audio.Samples.Get(@"Menu/osu-logo-downbeat"); + + SampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat"); + SampleDownbeat = audio.Samples.Get(@"Menu/osu-logo-downbeat"); logo.Texture = textures.Get(@"Menu/logo"); ripple.Texture = textures.Get(@"Menu/logo"); @@ -298,12 +305,13 @@ namespace osu.Game.Screens.Menu { if (beatIndex % timingPoint.TimeSignature.Numerator == 0) { - sampleDownbeat?.Play(); + SampleDownbeat?.Play(); } else { - var channel = sampleBeat.GetChannel(); - channel.Frequency.Value = 0.95 + RNG.NextDouble(0.1); + var channel = SampleBeat.GetChannel(); + + channel.Frequency.Value = 1 - BeatSampleVariance / 2 + RNG.NextDouble(BeatSampleVariance); channel.Play(); } }); diff --git a/osu.Game/Screens/Menu/StarFountainSounds.cs b/osu.Game/Screens/Menu/StarFountainSounds.cs new file mode 100644 index 0000000000..842e718c48 --- /dev/null +++ b/osu.Game/Screens/Menu/StarFountainSounds.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Game.Audio; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Menu +{ + public partial class StarFountainSounds : CompositeComponent + { + private const int shoot_retrigger_delay = 500; + private const int loop_fade_duration = 500; + + private double? lastPlayback; + + private SkinnableSound shootSample = null!; + private PausableSkinnableSound loopSample = null!; + + private ScheduledDelegate? loopFadeDelegate; + private ScheduledDelegate? loopStopDelegate; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + shootSample = new SkinnableSound(new SampleInfo("Gameplay/fountain-shoot")), + loopSample = new PausableSkinnableSound(new SampleInfo("Gameplay/fountain-loop")) { Looping = true }, + }; + } + + public void Play() + { + loopFadeDelegate?.Cancel(); + loopStopDelegate?.Cancel(); + + try + { + // Only play 'shootSample' if enough time has passed since last `Play()` call. + if (lastPlayback == null || Time.Current - lastPlayback > shoot_retrigger_delay) + { + loopSample.Stop(); + shootSample.Play(); + return; + } + + // Only call `Play()` if `loopSample` is not already playing, to prevent restarting the sample each time. + if (!loopSample.RequestedPlaying) + { + this.TransformBindableTo(loopSample.Volume, 1); + loopSample.Play(); + } + + // Schedule a volume fadeout, followed by a `Stop()`. + loopFadeDelegate = Scheduler.AddDelayed(() => + { + this.TransformBindableTo(loopSample.Volume, 0, loop_fade_duration); + loopStopDelegate = Scheduler.AddDelayed(() => loopSample.Stop(), loop_fade_duration); + }, shoot_retrigger_delay); + } + finally + { + lastPlayback = Time.Current; + } + } + } +} diff --git a/osu.Game/Screens/Menu/SupporterDisplay.cs b/osu.Game/Screens/Menu/SupporterDisplay.cs index 6639300f4a..be50a54619 100644 --- a/osu.Game/Screens/Menu/SupporterDisplay.cs +++ b/osu.Game/Screens/Menu/SupporterDisplay.cs @@ -100,7 +100,6 @@ namespace osu.Game.Screens.Menu t.Padding = new MarginPadding { Left = 5, Top = 1 }; t.Font = t.Font.With(size: font_size); - t.Origin = Anchor.Centre; t.Colour = colours.Pink; Schedule(() => diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs deleted file mode 100644 index b213d424df..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - /// - /// A that polls for the lounge listing. - /// - public partial class ListingPollingComponent : RoomPollingComponent - { - public IBindable InitialRoomsReceived => initialRoomsReceived; - private readonly Bindable initialRoomsReceived = new Bindable(); - - public readonly Bindable Filter = new Bindable(); - - [BackgroundDependencyLoader] - private void load() - { - Filter.BindValueChanged(_ => - { - RoomManager.ClearRooms(); - initialRoomsReceived.Value = false; - - if (IsLoaded) - PollImmediately(); - }); - } - - private GetRoomsRequest? lastPollRequest; - - protected override Task Poll() - { - if (!API.IsLoggedIn) - return base.Poll(); - - if (Filter.Value == null) - return base.Poll(); - - var tcs = new TaskCompletionSource(); - - lastPollRequest?.Cancel(); - - var req = new GetRoomsRequest(Filter.Value.Status, Filter.Value.Category); - - req.Success += result => - { - result = result.Where(r => r.Category != RoomCategory.DailyChallenge).ToList(); - - foreach (var existing in RoomManager.Rooms.ToArray()) - { - if (result.All(r => r.RoomID != existing.RoomID)) - RoomManager.RemoveRoom(existing); - } - - foreach (var incoming in result) - RoomManager.AddOrUpdateRoom(incoming); - - initialRoomsReceived.Value = true; - tcs.SetResult(true); - }; - - req.Failure += _ => tcs.SetResult(false); - - API.Queue(req); - - lastPollRequest = req; - return tcs.Task; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs index d9cdcac7d7..6dfde183f0 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs @@ -53,13 +53,11 @@ namespace osu.Game.Screens.OnlinePlay.Components { RelativeSizeAxes = Axes.X, Height = 2, - Margin = new MarginPadding { Bottom = 2 } }, new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, Spacing = new Vector2(10, 0), Children = new Drawable[] { diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 2e669fd1b2..56e2719e9c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online; @@ -11,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract partial class ReadyButton : RoundedButton, IHasTooltip + public abstract partial class ReadyButton : RoundedButton { public new readonly BindableBool Enabled = new BindableBool(); @@ -29,7 +28,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private void updateState() => base.Enabled.Value = availability.Value.State == DownloadState.LocallyAvailable && Enabled.Value; - public virtual LocalisableString TooltipText + public override LocalisableString TooltipText { get { diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs deleted file mode 100644 index 73f980f0a3..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Development; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public partial class RoomManager : Component, IRoomManager - { - public event Action? RoomsUpdated; - - private readonly BindableList rooms = new BindableList(); - - public IBindableList Rooms => rooms; - - protected IBindable JoinedRoom => joinedRoom; - private readonly Bindable joinedRoom = new Bindable(); - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - public RoomManager() - { - RelativeSizeAxes = Axes.Both; - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - PartRoom(); - } - - public virtual void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - { - room.Host = api.LocalUser.Value; - - var req = new CreateRoomRequest(room); - - req.Success += result => - { - joinedRoom.Value = room; - - AddOrUpdateRoom(result); - room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. - - // The server may not contain all properties (such as password), so invoke success with the given room. - onSuccess?.Invoke(room); - }; - - req.Failure += exception => - { - onError?.Invoke(req.Response?.Error ?? exception.Message); - }; - - api.Queue(req); - } - - private JoinRoomRequest? currentJoinRoomRequest; - - public virtual void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room, password); - - currentJoinRoomRequest.Success += result => - { - joinedRoom.Value = room; - - AddOrUpdateRoom(result); - room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. - - onSuccess?.Invoke(room); - }; - - currentJoinRoomRequest.Failure += exception => - { - if (exception is OperationCanceledException) - return; - - onError?.Invoke(exception.Message); - }; - - api.Queue(currentJoinRoomRequest); - } - - public virtual void PartRoom() - { - currentJoinRoomRequest?.Cancel(); - - if (joinedRoom.Value == null) - return; - - if (api.State.Value == APIState.Online) - api.Queue(new PartRoomRequest(joinedRoom.Value)); - - joinedRoom.Value = null; - } - - private readonly HashSet ignoredRooms = new HashSet(); - - public void AddOrUpdateRoom(Room room) - { - Debug.Assert(ThreadSafety.IsUpdateThread); - Debug.Assert(room.RoomID != null); - - if (ignoredRooms.Contains(room.RoomID.Value)) - return; - - try - { - var existing = rooms.FirstOrDefault(e => e.RoomID == room.RoomID); - if (existing == null) - rooms.Add(room); - else - existing.CopyFrom(room); - } - catch (Exception ex) - { - Logger.Error(ex, $"Failed to update room: {room.Name}."); - - ignoredRooms.Add(room.RoomID.Value); - rooms.Remove(room); - } - - notifyRoomsUpdated(); - } - - public void RemoveRoom(Room room) - { - Debug.Assert(ThreadSafety.IsUpdateThread); - - rooms.Remove(room); - notifyRoomsUpdated(); - } - - public void ClearRooms() - { - Debug.Assert(ThreadSafety.IsUpdateThread); - - rooms.Clear(); - notifyRoomsUpdated(); - } - - private void notifyRoomsUpdated() - { - Scheduler.AddOnce(invokeRoomsUpdated); - - void invokeRoomsUpdated() => RoomsUpdated?.Invoke(); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs deleted file mode 100644 index 0ba7f20f1c..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Game.Online; -using osu.Game.Online.API; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public abstract partial class RoomPollingComponent : PollingComponent - { - [Resolved] - protected IAPIProvider API { get; private set; } = null!; - - [Resolved] - protected IRoomManager RoomManager { get; private set; } = null!; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 2bdb41ce12..e2aecb6781 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -14,7 +14,6 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Online.Rooms; using osuTK; -using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Components { @@ -30,6 +29,8 @@ namespace osu.Game.Screens.OnlinePlay.Components private StarRatingDisplay maxDisplay = null!; private Drawable maxBackground = null!; + private BufferedContainer bufferedContent = null!; + public StarRatingRangeDisplay(Room room) { this.room = room; @@ -41,38 +42,43 @@ namespace osu.Game.Screens.OnlinePlay.Components { InternalChildren = new Drawable[] { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 1, - Children = new[] - { - minBackground = new Box - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f), - }, - maxBackground = new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f), - }, - } - }, - new FillFlowContainer + new CircularContainer { AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Masking = true, + // Stops artifacting from boxes drawn behind wrong colour boxes (and edge pixels adding up to higher opacity). + Padding = new MarginPadding(-0.1f), + Child = bufferedContent = new BufferedContainer(pixelSnapping: true, cachedFrameBuffer: true) { - minDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range), - maxDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range) + AutoSizeAxes = Axes.Both, + Children = new[] + { + minBackground = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1, 0.5f), + }, + maxBackground = new Box + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1, 0.5f), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + minDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range), + maxDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range) + } + } + } } - } + }, }; } @@ -121,6 +127,8 @@ namespace osu.Game.Screens.OnlinePlay.Components minBackground.Colour = colours.ForStarDifficulty(minDifficulty.Stars); maxBackground.Colour = colours.ForStarDifficulty(maxDifficulty.Stars); + + bufferedContent.ForceRedraw(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs index 2b1233506f..7147803412 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs @@ -29,18 +29,28 @@ namespace osu.Game.Screens.OnlinePlay.Components base.LoadComplete(); room.PropertyChanged += onRoomPropertyChanged; + + // Timed update required to track rooms which have hit the end time, see `HasEnded`. + Scheduler.AddDelayed(updateRoomStatus, 1000, true); updateRoomStatus(); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(Room.Status)) - updateRoomStatus(); + switch (e.PropertyName) + { + case nameof(Room.Category): + case nameof(Room.Status): + case nameof(Room.EndDate): + case nameof(Room.HasPassword): + updateRoomStatus(); + break; + } } private void updateRoomStatus() { - this.FadeColour(colours.ForRoomCategory(room.Category) ?? room.Status.GetAppropriateColour(colours), transitionDuration); + this.FadeColour(colours.ForRoomCategory(room.Category) ?? colours.ForRoomStatus(room), transitionDuration); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs index 13a282dd52..5c8b500c93 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -34,12 +34,12 @@ using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; +using osu.Game.Users; using osuTK; namespace osu.Game.Screens.OnlinePlay.DailyChallenge @@ -71,9 +71,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - [Cached(Type = typeof(IRoomManager))] - private RoomManager roomManager { get; set; } - [Cached] private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -111,11 +108,12 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge public override bool? ApplyModTrackAdjustments => true; + protected override UserActivity InitialActivity => new UserActivity.InDailyChallengeLobby(); + public DailyChallenge(Room room) { this.room = room; playlistItem = room.Playlist.Single(); - roomManager = new RoomManager(); Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; } @@ -131,7 +129,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - roomManager, beatmapAvailabilityTracker, new ScreenStack(new RoomBackgroundScreen(playlistItem)) { @@ -161,7 +158,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { new Drawable[] { - new DrawableRoomPlaylistItem(playlistItem) + new DrawableRoomPlaylistItem(playlistItem, true) { RelativeSizeAxes = Axes.X, AllowReordering = false, @@ -426,7 +423,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge base.OnEntering(e); waves.Show(); - roomManager.JoinRoom(room); + API.Queue(new JoinRoomRequest(room, null)); startLoopingTrack(this, musicController); metadataClient.BeginWatchingMultiplayerRoom(room.RoomID!.Value).ContinueWith(t => @@ -480,7 +477,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge previewTrackManager.StopAnyPlaying(this); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); - roomManager.PartRoom(); + API.Queue(new PartRoomRequest(room)); metadataClient.EndWatchingMultiplayerRoom(room.RoomID!.Value).FireAndForget(); return base.OnExiting(e); @@ -532,7 +529,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private void startPlay() { sampleStart?.Play(); - this.Push(new PlayerLoader(() => new PlaylistsPlayer(room, playlistItem) + this.Push(new PlayerLoader(() => new DailyChallengePlayer(room, playlistItem) { Exited = () => Scheduler.AddOnce(() => leaderboard.RefetchScores()) })); diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs index 9fe2b70a5a..4736ba28db 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -142,10 +142,10 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge request.Success += req => Schedule(() => { - var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo)).ToArray(); + var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo)).ToArray(); userBestScore.Value = req.UserScore; - var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo); + var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo); cancellationTokenSource?.Cancel(); cancellationTokenSource = null; diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.cs new file mode 100644 index 0000000000..a5c61b8386 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Screens.Play; +using osu.Game.Users; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengePlayer : PlaylistsPlayer + { + protected override UserActivity InitialActivity => new UserActivity.PlayingDailyChallenge(Beatmap.Value.BeatmapInfo, Ruleset.Value); + + public DailyChallengePlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration? configuration = null) + : base(room, playlistItem, configuration) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 207e0bdf55..c9d8365852 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -204,7 +204,7 @@ namespace osu.Game.Screens.OnlinePlay ScrollContainer.ScrollIntoView(drawableItem); } - #region Key selection logic (shared with BeatmapCarousel and RoomsContainer) + #region Key selection logic (shared with BeatmapCarousel and RoomListing) public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 7a773bb116..1e1e79d256 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -74,7 +74,7 @@ namespace osu.Game.Screens.OnlinePlay public bool IsSelectedItem => SelectedItem.Value?.ID == Item.ID; - private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both }; + private readonly DelayedLoadWrapper onScreenLoader; private readonly IBindable valid = new Bindable(); private IBeatmapInfo? beatmap; @@ -120,9 +120,11 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(CanBeNull = true)] private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - public DrawableRoomPlaylistItem(PlaylistItem item) + public DrawableRoomPlaylistItem(PlaylistItem item, bool loadImmediately = false) : base(item) { + onScreenLoader = new DelayedLoadWrapper(Empty, timeBeforeLoad: loadImmediately ? 0 : 500) { RelativeSizeAxes = Axes.Both }; + Item = item; valid.BindTo(item.Valid); diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index dd6536cf26..3605412b2b 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -11,33 +11,26 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osuTK; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonFreeMods : FooterButton { - private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); + public readonly Bindable> FreeMods = new Bindable>(); + public readonly IBindable Freestyle = new Bindable(); - public Bindable> Current - { - get => current.Current; - set - { - ArgumentNullException.ThrowIfNull(value); + protected override bool IsActive => FreeMods.Value.Count > 0; - current.Current = value; - } - } + public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } private OsuSpriteText count = null!; - private Circle circle = null!; private readonly FreeModSelectOverlay freeModSelectOverlay; @@ -45,6 +38,9 @@ namespace osu.Game.Screens.OnlinePlay public FooterButtonFreeMods(FreeModSelectOverlay freeModSelectOverlay) { this.freeModSelectOverlay = freeModSelectOverlay; + + // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. + base.Action = toggleAllFreeMods; } [Resolved] @@ -91,16 +87,16 @@ namespace osu.Game.Screens.OnlinePlay SelectedColour = colours.Yellow; DeselectedColour = SelectedColour.Opacity(0.5f); Text = @"freemods"; + + TooltipText = MultiplayerMatchStrings.FreeModsButtonTooltip; } protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateModDisplay(), true); - - // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. - Action = toggleAllFreeMods; + Freestyle.BindValueChanged(_ => updateModDisplay()); + FreeMods.BindValueChanged(_ => updateModDisplay(), true); } /// @@ -110,16 +106,16 @@ namespace osu.Game.Screens.OnlinePlay { var availableMods = allAvailableAndValidMods.ToArray(); - Current.Value = Current.Value.Count == availableMods.Length + FreeMods.Value = FreeMods.Value.Count == availableMods.Length ? Array.Empty() : availableMods; } private void updateModDisplay() { - int currentCount = Current.Value.Count; + int currentCount = FreeMods.Value.Count; - if (currentCount == allAvailableAndValidMods.Count()) + if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) { count.Text = "all"; count.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs new file mode 100644 index 0000000000..6ee983af20 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.OnlinePlay +{ + public partial class FooterButtonFreestyle : FooterButton + { + public readonly Bindable Freestyle = new Bindable(); + + protected override bool IsActive => Freestyle.Value; + + public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } + + private OsuSpriteText text = null!; + private Circle circle = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FooterButtonFreestyle() + { + // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. + base.Action = () => Freestyle.Value = !Freestyle.Value; + } + + [BackgroundDependencyLoader] + private void load() + { + ButtonContentContainer.AddRange(new[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + circle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(5), + UseFullGlyphHeight = false, + } + } + } + }); + + SelectedColour = colours.Yellow; + DeselectedColour = SelectedColour.Opacity(0.5f); + Text = @"freestyle"; + + TooltipText = MultiplayerMatchStrings.FreestyleButtonTooltip; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Freestyle.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + if (Freestyle.Value) + { + text.Text = "on"; + text.FadeColour(colours.Gray2, 200, Easing.OutQuint); + circle.FadeColour(colours.Yellow, 200, Easing.OutQuint); + } + else + { + text.Text = "off"; + text.FadeColour(colours.GrayF, 200, Easing.OutQuint); + circle.FadeColour(colours.Gray4, 200, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs deleted file mode 100644 index ed4fb7b15e..0000000000 --- a/osu.Game/Screens/OnlinePlay/IRoomManager.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay -{ - [Cached(typeof(IRoomManager))] - public interface IRoomManager - { - /// - /// Invoked when the s have been updated. - /// - event Action RoomsUpdated; - - /// - /// All the active s. - /// - IBindableList Rooms { get; } - - /// - /// Adds a to this . - /// If already existing, the local room will be updated with the given one. - /// - /// The incoming . - void AddOrUpdateRoom(Room room); - - /// - /// Removes a from this . - /// - /// The to remove. - void RemoveRoom(Room room); - - /// - /// Removes all s from this . - /// - void ClearRooms(); - - /// - /// Creates a new . - /// - /// The to create. - /// An action to be invoked if the creation succeeds. - /// An action to be invoked if an error occurred. - void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null); - - /// - /// Joins a . - /// - /// The to join. must be populated. - /// An optional password to use for the join operation. - /// - /// - void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null); - - /// - /// Parts the currently-joined . - /// - void PartRoom(); - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index c39ca347c7..491d8071f1 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -12,15 +12,19 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -31,11 +35,17 @@ using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public abstract partial class DrawableRoom : CompositeDrawable + public abstract partial class DrawableRoom : CompositeDrawable, IHasContextMenu { protected const float CORNER_RADIUS = 10; private const float height = 100; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + public readonly Room Room; protected readonly Bindable SelectedItem = new Bindable(); @@ -169,6 +179,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, + new FreestyleStatusPill(Room) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, endDateInfo = new EndDateInfo(Room) { Anchor = Anchor.CentreLeft, @@ -330,6 +345,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } + public virtual MenuItem[] ContextMenuItems + { + get + { + var items = new List(); + + if (Room.RoomID.HasValue) + { + items.AddRange([ + new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(formatRoomUrl(Room.RoomID.Value))), + new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(formatRoomUrl(Room.RoomID.Value))) + ]); + } + + return items.ToArray(); + + string formatRoomUrl(long id) => $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{id}"; + } + } + protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); protected virtual IEnumerable CreateBottomDetails() diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs index 0f63718355..121dffde1f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs @@ -8,7 +8,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public class FilterCriteria { public string SearchString = string.Empty; - public RoomStatusFilter Status; + public RoomModeFilter Mode; + public RoomStatusFilter? Status; public string Category = string.Empty; public RulesetInfo? Ruleset; public RoomPermissionsFilter Permissions; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.cs new file mode 100644 index 0000000000..b306e27f84 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.Rooms; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public partial class FreestyleStatusPill : OnlinePlayPill + { + private readonly Room room; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); + + public FreestyleStatusPill(Room room) + { + this.room = room; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Pill.Background.Alpha = 1; + Pill.Background.Colour = colours.Yellow; + + TextFlow.Text = "Freestyle"; + TextFlow.Colour = Color4.Black; + + room.PropertyChanged += onRoomPropertyChanged; + updateFreestyleStatus(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.CurrentPlaylistItem): + case nameof(Room.Playlist): + updateFreestyleStatus(); + break; + } + } + + private void updateFreestyleStatus() + { + PlaylistItem? currentItem = room.Playlist.GetCurrentItem() ?? room.CurrentPlaylistItem; + Alpha = currentItem?.Freestyle == true ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs similarity index 58% rename from osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs rename to osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs index 17aed021b2..0276601656 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs @@ -5,15 +5,15 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Globalization; using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; @@ -21,51 +21,71 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class RoomsContainer : CompositeDrawable, IKeyBindingHandler + public partial class RoomListing : CompositeDrawable, IKeyBindingHandler { - public readonly Bindable SelectedRoom = new Bindable(); + /// + /// Rooms which should be displayed. Should be managed externally. + /// + public readonly BindableList Rooms = new BindableList(); + + /// + /// The current filter criteria. Should be managed externally. + /// public readonly Bindable Filter = new Bindable(); - public IReadOnlyList Rooms => roomFlow.FlowingChildren.Cast().ToArray(); + /// + /// The currently user-selected room. + /// + public IBindable SelectedRoom => selectedRoom; - private readonly IBindableList rooms = new BindableList(); + private readonly Bindable selectedRoom = new Bindable(); + + public IReadOnlyList DrawableRooms => roomFlow.FlowingChildren.Cast().ToArray(); + + private readonly ScrollContainer scroll; private readonly FillFlowContainer roomFlow; - [Resolved] - private IRoomManager roomManager { get; set; } = null!; + private const float display_scale = 0.8f; // handle deselection public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public RoomsContainer() + public RoomListing() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - // account for the fact we are in a scroll container and want a bit of spacing from the scroll bar. - Padding = new MarginPadding { Right = 5 }; - - InternalChild = new OsuContextMenuContainer + InternalChild = scroll = new Scroll { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = roomFlow = new FillFlowContainer + Masking = false, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = display_scale, + ScrollbarOverlapsContent = false, + Padding = new MarginPadding { Right = 5 }, + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), + Child = roomFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Margin = new MarginPadding { Vertical = 10 }, + } } }; } + private partial class Scroll : OsuScrollContainer + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + } + protected override void LoadComplete() { - rooms.CollectionChanged += roomsChanged; - roomManager.RoomsUpdated += updateSorting; - - rooms.BindTo(roomManager.Rooms); - + SelectedRoom.BindValueChanged(onSelectedRoomChanged, true); + Rooms.BindCollectionChanged(roomsChanged, true); Filter.BindValueChanged(criteria => applyFilterCriteria(criteria.NewValue), true); } @@ -80,19 +100,34 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components bool matchingFilter = true; matchingFilter &= criteria.Ruleset == null || r.Room.PlaylistItemStats?.RulesetIDs.Any(id => id == criteria.Ruleset.OnlineID) != false; - - if (!string.IsNullOrEmpty(criteria.SearchString)) - { - // Room name isn't translatable, so ToString() is used here for simplicity. - matchingFilter &= r.FilterTerms.Any(term => term.ToString().Contains(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase)); - } - matchingFilter &= matchPermissions(r, criteria.Permissions); + // Room name isn't translatable, so ToString() is used here for simplicity. + string[] filterTerms = r.FilterTerms.Select(t => t.ToString()).ToArray(); + string[] searchTerms = criteria.SearchString.Split(' ', StringSplitOptions.RemoveEmptyEntries); + matchingFilter &= searchTerms.All(searchTerm => filterTerms.Any(filterTerm => checkTerm(filterTerm, searchTerm))); + r.MatchingFilter = matchingFilter; } }); + // Lifted from SearchContainer. + static bool checkTerm(string haystack, string needle) + { + int index = 0; + + for (int i = 0; i < needle.Length; i++) + { + int found = CultureInfo.InvariantCulture.CompareInfo.IndexOf(haystack, needle[i], index, CompareOptions.OrdinalIgnoreCase); + if (found < 0) + return false; + + index = found + 1; + } + + return true; + } + static bool matchPermissions(DrawableLoungeRoom room, RoomPermissionsFilter accessType) { switch (accessType) @@ -112,6 +147,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } + private void onSelectedRoomChanged(ValueChangedEvent room) + { + // scroll selected room into view on selection. + var drawable = DrawableRooms.FirstOrDefault(r => r.Room == room.NewValue); + if (drawable != null) + scroll.ScrollIntoView(drawable); + } + private void roomsChanged(object? sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) @@ -139,7 +182,21 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void addRooms(IEnumerable rooms) { foreach (var room in rooms) - roomFlow.Add(new DrawableLoungeRoom(room) { SelectedRoom = SelectedRoom }); + { + var drawableRoom = new DrawableLoungeRoom(room) + { + SelectedRoom = selectedRoom, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(display_scale), + Width = 1 / display_scale, + }; + + roomFlow.Add(drawableRoom); + + // Always show spotlight playlists at the top of the listing. + roomFlow.SetLayoutPosition(drawableRoom, room.Category > RoomCategory.Normal ? float.MinValue : -(room.RoomID ?? 0)); + } applyFilterCriteria(Filter.Value); } @@ -152,7 +209,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // selection may have a lease due to being in a sub screen. if (SelectedRoom.Value == r && !SelectedRoom.Disabled) - SelectedRoom.Value = null; + selectedRoom.Value = null; } } @@ -162,24 +219,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // selection may have a lease due to being in a sub screen. if (!SelectedRoom.Disabled) - SelectedRoom.Value = null; - } - - private void updateSorting() - { - foreach (var room in roomFlow) - { - roomFlow.SetLayoutPosition(room, room.Room.Category > RoomCategory.Normal - // Always show spotlight playlists at the top of the listing. - ? float.MinValue - : -(room.Room.RoomID ?? 0)); - } + selectedRoom.Value = null; } protected override bool OnClick(ClickEvent e) { if (!SelectedRoom.Disabled) - SelectedRoom.Value = null; + selectedRoom.Value = null; return base.OnClick(e); } @@ -210,7 +256,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components if (SelectedRoom.Disabled) return; - var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); + var visibleRooms = DrawableRooms.AsEnumerable().Where(r => r.IsPresent); Room? room; @@ -226,17 +272,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // we already have a valid selection only change selection if we still have a room to switch to. if (room != null) - SelectedRoom.Value = room; + selectedRoom.Value = room; } #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (roomManager.IsNotNull()) - roomManager.RoomsUpdated -= updateSorting; - } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs new file mode 100644 index 0000000000..0c07233bff --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public enum RoomModeFilter + { + Open, + + [Description("Recently Ended")] + Ended, + Participated, + Owned, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs index 53fbf670e1..a4d5043ff5 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs @@ -1,17 +1,11 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; - namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public enum RoomStatusFilter { - Open, - - [Description("Recently Ended")] - Ended, - Participated, - Owned, + Idle, + Playing, } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index b3dc617fd6..092f17a643 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { @@ -35,8 +36,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Pill.Background.Alpha = 1; room.PropertyChanged += onRoomPropertyChanged; - updateDisplay(); + // Timed update required to track rooms which have hit the end time, see `HasEnded`. + Scheduler.AddDelayed(updateDisplay, 1000, true); + updateDisplay(); FinishTransforms(true); } @@ -46,6 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { case nameof(Room.Status): case nameof(Room.EndDate): + case nameof(Room.HasPassword): updateDisplay(); break; } @@ -53,8 +57,23 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void updateDisplay() { - Pill.Background.FadeColour(room.Status.GetAppropriateColour(colours), 100); - TextFlow.Text = room.Status.Message; + Pill.Background.FadeColour(colours.ForRoomStatus(room), 100); + + if (room.HasEnded) + TextFlow.Text = RoomStatusPillStrings.Ended; + else + { + switch (room.Status) + { + case RoomStatus.Playing: + TextFlow.Text = RoomStatusPillStrings.Playing; + break; + + default: + TextFlow.Text = room.HasPassword ? RoomStatusPillStrings.OpenPrivate : RoomStatusPillStrings.Open; + break; + } + } } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index d396d18b4f..d369722e5f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.ComponentModel; using osu.Framework.Allocation; @@ -22,9 +23,12 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input.Bindings; +using osu.Game.Online.API; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Playlists; using osuTK; using osuTK.Graphics; using Container = osu.Framework.Graphics.Containers.Container; @@ -34,7 +38,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// /// A with lounge-specific interactions such as selection and hover sounds. /// - public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler + public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasPopover, IKeyBindingHandler { private const float transition_duration = 60; private const float selection_border_width = 4; @@ -46,7 +50,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge } [Resolved(canBeNull: true)] - private LoungeSubScreen? lounge { get; set; } + private IOnlinePlayLounge? lounge { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; private readonly BindableWithCurrent selectedRoom = new BindableWithCurrent(); private Sample? sampleSelect; @@ -144,13 +154,28 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public Popover GetPopover() => new PasswordEntryPopover(Room); - public MenuItem[] ContextMenuItems => new MenuItem[] + public override MenuItem[] ContextMenuItems { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => + get { - lounge?.OpenCopy(Room); - }) - }; + var items = new List(); + + items.AddRange(base.ContextMenuItems); + + items.Add(new OsuMenuItemSpacer()); + items.Add(new OsuMenuItem("Create copy", MenuItemType.Standard, () => lounge?.OpenCopy(Room))); + + if (Room.Type == MatchType.Playlists && Room.Host?.Id == api.LocalUser.Value.Id && Room.StartDate?.AddMinutes(5) >= DateTimeOffset.Now && !Room.HasEnded) + { + items.Add(new OsuMenuItem("Close playlist", MenuItemType.Destructive, () => + { + dialogOverlay?.Push(new ClosePlaylistDialog(Room, () => lounge?.Close(Room))); + })); + } + + return items.ToArray(); + } + } public bool OnPressed(KeyBindingPressEvent e) { @@ -207,7 +232,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private readonly Room room; [Resolved(canBeNull: true)] - private LoungeSubScreen? lounge { get; set; } + private IOnlinePlayLounge? lounge { get; set; } public override bool HandleNonPositionalInput => true; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.cs b/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.cs new file mode 100644 index 0000000000..73ab84af13 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + public interface IOnlinePlayLounge + { + /// + /// Attempts to join the given room. + /// + /// The room to join. + /// The password. + /// A delegate to invoke if the user joined the room. + /// A delegate to invoke if the user is not able join the room. + void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null); + + /// + /// Copies the given room and opens it as a fresh (not-yet-created) one. + /// + /// The room to copy. + void OpenCopy(Room room); + + /// + /// Closes the given room. + /// + /// The room to close. + void Close(Room room); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs new file mode 100644 index 0000000000..d92ae7eb6e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + /// + /// Polls for rooms for the main lounge listing. + /// + public partial class LoungeListingPoller : PollingComponent + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public required Action RoomsReceived { get; init; } + public readonly IBindable Filter = new Bindable(); + + private GetRoomsRequest? lastPollRequest; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + if (Filter.Value == null) + return base.Poll(); + + lastPollRequest?.Cancel(); + + var tcs = new TaskCompletionSource(); + var req = new GetRoomsRequest(Filter.Value); + + req.Success += result => + { + RoomsReceived(result.Where(r => r.Category != RoomCategory.DailyChallenge).ToArray()); + tcs.SetResult(true); + }; + + req.Failure += _ => tcs.SetResult(false); + + api.Queue(req); + + lastPollRequest = req; + + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index ac8caa6b88..c455020f9a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -8,38 +8,40 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; -using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Users; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge { [Cached] - public abstract partial class LoungeSubScreen : OnlinePlaySubScreen + [Cached(typeof(IOnlinePlayLounge))] + public abstract partial class LoungeSubScreen : OnlinePlaySubScreen, IOnlinePlayLounge { public override string Title => "Lounge"; protected override BackgroundScreen CreateBackground() => new LoungeBackgroundScreen { - SelectedRoom = { BindTarget = SelectedRoom } + SelectedRoom = { BindTarget = roomListing.SelectedRoom } }; protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby(); @@ -51,10 +53,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge AutoSizeAxes = Axes.Both }; - protected ListingPollingComponent ListingPollingComponent { get; private set; } = null!; - - protected readonly Bindable SelectedRoom = new Bindable(); - [Resolved] private MusicController music { get; set; } = null!; @@ -73,31 +71,39 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [Resolved] protected OsuConfigManager Config { get; private set; } = null!; - private IDisposable? joiningRoomOperation { get; set; } - private LeasedBindable? selectionLease; + private IDisposable? joiningRoomOperation; private readonly Bindable filter = new Bindable(); + private readonly Bindable hasListingResults = new Bindable(); private readonly IBindable operationInProgress = new Bindable(); private readonly IBindable isIdle = new BindableBool(); + private RoomListing roomListing = null!; + private LoungeListingPoller listingPoller = null!; private PopoverContainer popoverContainer = null!; private LoadingLayer loadingLayer = null!; - private RoomsContainer roomsContainer = null!; private SearchTextBox searchTextBox = null!; - private Dropdown statusDropdown = null!; + + protected Dropdown StatusDropdown { get; private set; } = null!; [BackgroundDependencyLoader(true)] private void load() { + Masking = true; + const float controls_area_height = 25f; if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); - OsuScrollContainer scrollContainer; + Color4 bg = Color4Extensions.FromHex("#070405"); InternalChildren = new Drawable[] { - ListingPollingComponent = CreatePollingComponent().With(c => c.Filter.BindTarget = filter), + listingPoller = new LoungeListingPoller + { + RoomsReceived = onListingReceived, + Filter = { BindTarget = filter } + }, popoverContainer = new PopoverContainer { Name = @"Rooms area", @@ -107,78 +113,89 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Horizontal = WaveOverlayContainer.WIDTH_PADDING, Top = Header.HEIGHT + controls_area_height + 20, }, - Child = scrollContainer = new OsuScrollContainer + Child = roomListing = new RoomListing { RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - Child = roomsContainer = new RoomsContainer - { - Filter = { BindTarget = filter }, - SelectedRoom = { BindTarget = SelectedRoom } - } - }, + Filter = { BindTarget = filter }, + } }, loadingLayer = new LoadingLayer(true), - new FillFlowContainer + new Container { - Name = @"Header area flow", + Name = "Header area", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, - Direction = FillDirection.Vertical, Children = new Drawable[] { - new Container + new Box { - RelativeSizeAxes = Axes.X, - Height = Header.HEIGHT, - Child = searchTextBox = new BasicSearchTextBox - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.X, - Width = 0.6f, - }, + Colour = ColourInfo.GradientVertical(bg, bg.Opacity(0.75f)), + RelativeSizeAxes = Axes.Both, + Height = 0.8f, }, - new Container + new Box { + Colour = ColourInfo.GradientVertical(bg.Opacity(0.75f), bg.Opacity(0)), + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Y = 0.8f, + // Intentionally taller than the header for a more gradual fade + Height = 0.5f, + }, + new FillFlowContainer + { + Name = @"Header area flow", RelativeSizeAxes = Axes.X, - Height = controls_area_height, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, + Direction = FillDirection.Vertical, Children = new Drawable[] { - Buttons.WithChild(CreateNewRoomButton().With(d => + new Container { - d.Anchor = Anchor.BottomLeft; - d.Origin = Anchor.BottomLeft; - d.Size = new Vector2(150, 37.5f); - d.Action = () => Open(); - })), - new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10), - ChildrenEnumerable = CreateFilterControls().Select(f => f.With(d => + RelativeSizeAxes = Axes.X, + Height = Header.HEIGHT, + Child = searchTextBox = new BasicSearchTextBox { - d.Anchor = Anchor.TopRight; - d.Origin = Anchor.TopRight; - })) + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Width = 0.6f, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = controls_area_height, + Children = new Drawable[] + { + Buttons.WithChild(CreateNewRoomButton().With(d => + { + d.Anchor = Anchor.BottomLeft; + d.Origin = Anchor.BottomLeft; + d.Size = new Vector2(150, 37.5f); + d.Action = () => Open(); + })), + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + ChildrenEnumerable = CreateFilterControls().Select(f => f.With(d => + { + d.Anchor = Anchor.TopRight; + d.Origin = Anchor.TopRight; + })) + } + } } - } - } - }, + }, + }, + } }, }; - - // scroll selected room into view on selection. - SelectedRoom.BindValueChanged(val => - { - var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); - if (drawable != null) - scrollContainer.ScrollIntoView(drawable); - }); } protected override void LoadComplete() @@ -187,7 +204,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge searchTextBox.Current.BindValueChanged(_ => updateFilterDebounced()); ruleset.BindValueChanged(_ => UpdateFilter()); - isIdle.BindValueChanged(_ => updatePollingRate(this.IsCurrentScreen()), true); if (ongoingOperationTracker != null) @@ -196,11 +212,38 @@ namespace osu.Game.Screens.OnlinePlay.Lounge operationInProgress.BindValueChanged(_ => updateLoadingLayer()); } - ListingPollingComponent.InitialRoomsReceived.BindValueChanged(_ => updateLoadingLayer(), true); + hasListingResults.BindValueChanged(_ => updateLoadingLayer()); + filter.BindValueChanged(_ => + { + roomListing.Rooms.Clear(); + RefreshRooms(); + }); + + updateLoadingLayer(); updateFilter(); } + private void onListingReceived(Room[] result) + { + Dictionary localRoomsById = roomListing.Rooms.ToDictionary(r => r.RoomID!.Value); + Dictionary resultRoomsById = result.ToDictionary(r => r.RoomID!.Value); + + // Remove all local rooms no longer in the result set. + roomListing.Rooms.RemoveAll(r => !resultRoomsById.ContainsKey(r.RoomID!.Value)); + + // Add or update local rooms with the result set. + foreach (var r in result) + { + if (localRoomsById.TryGetValue(r.RoomID!.Value, out Room? existingRoom)) + existingRoom.CopyFrom(r); + else + roomListing.Rooms.Add(r); + } + + hasListingResults.Value = true; + } + #region Filtering public void UpdateFilter() => Scheduler.AddOnce(updateFilter); @@ -223,20 +266,20 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { SearchString = searchTextBox.Current.Value, Ruleset = ruleset.Value, - Status = statusDropdown.Current.Value + Mode = StatusDropdown.Current.Value }; protected virtual IEnumerable CreateFilterControls() { - statusDropdown = new SlimEnumDropdown + StatusDropdown = new SlimEnumDropdown { RelativeSizeAxes = Axes.None, Width = 160, }; - statusDropdown.Current.BindValueChanged(_ => UpdateFilter()); + StatusDropdown.Current.BindValueChanged(_ => UpdateFilter()); - yield return statusDropdown; + yield return StatusDropdown; } #endregion @@ -251,17 +294,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.OnResuming(e); - Debug.Assert(selectionLease != null); - - selectionLease.Return(); - selectionLease = null; - - if (SelectedRoom.Value?.RoomID == null) - SelectedRoom.Value = new Room(); - music.EnsurePlayingSomething(); onReturning(); + + // Poll for any newly-created rooms (including potentially the user's own). + listingPoller.PollImmediately(); } public override bool OnExiting(ScreenExitEvent e) @@ -296,14 +334,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge popoverContainer.HidePopover(); } - public virtual void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null) => Schedule(() => + public void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null) => Schedule(() => { if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, password, _ => + JoinInternal(room, password, r => { Open(room); joiningRoomOperation?.Dispose(); @@ -313,14 +351,16 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { joiningRoomOperation?.Dispose(); joiningRoomOperation = null; - onFailure?.Invoke(error); + + if (onFailure != null) + onFailure(error); + else + Logger.Log(error, level: LogLevel.Error); }); }); - /// - /// Copies a room and opens it as a fresh (not-yet-created) one. - /// - /// The room to copy. + protected abstract void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure); + public void OpenCopy(Room room) { Debug.Assert(room.RoomID != null); @@ -357,6 +397,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge api.Queue(req); } + public abstract void Close(Room room); + /// /// Push a room as a new subscreen. /// @@ -370,18 +412,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge OpenNewRoom(room ?? CreateNewRoom()); }); - protected virtual void OpenNewRoom(Room room) - { - selectionLease = SelectedRoom.BeginLease(false); - Debug.Assert(selectionLease != null); - selectionLease.Value = room; + protected virtual void OpenNewRoom(Room room) => this.Push(CreateRoomSubScreen(room)); - this.Push(CreateRoomSubScreen(room)); + public void RefreshRooms() + { + hasListingResults.Value = false; + listingPoller.PollImmediately(); } private void updateLoadingLayer() { - if (operationInProgress.Value || !ListingPollingComponent.InitialRoomsReceived.Value) + if (operationInProgress.Value || !hasListingResults.Value) loadingLayer.Show(); else loadingLayer.Hide(); @@ -390,11 +431,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private void updatePollingRate(bool isCurrentScreen) { if (!isCurrentScreen) - ListingPollingComponent.TimeBetweenPolls.Value = 0; + listingPoller.TimeBetweenPolls.Value = 0; else - ListingPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 120000 : 15000; + listingPoller.TimeBetweenPolls.Value = isIdle.Value ? 120000 : 15000; - Logger.Log($"Polling adjusted (listing: {ListingPollingComponent.TimeBetweenPolls.Value})"); + Logger.Log($"Polling adjusted (listing: {listingPoller.TimeBetweenPolls.Value})"); } protected abstract OsuButton CreateNewRoomButton(); @@ -405,8 +446,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// The created . protected abstract Room CreateNewRoom(); - protected abstract RoomSubScreen CreateRoomSubScreen(Room room); - - protected abstract ListingPollingComponent CreatePollingComponent(); + protected abstract OnlinePlaySubScreen CreateRoomSubScreen(Room room); } } diff --git a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs index 0c993f4abf..b10e83a05c 100644 --- a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Resources.Localisation.Web; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; @@ -26,12 +25,13 @@ namespace osu.Game.Screens.OnlinePlay.Match set => selectedItem.Current = value; } + public Drawable? ChangeSettingsButton { get; private set; } + [Resolved] private IAPIProvider api { get; set; } = null!; private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly bool allowEdit; - private Drawable? editButton; public DrawableMatchRoom(Room room, bool allowEdit = true) : base(room) @@ -46,11 +46,13 @@ namespace osu.Game.Screens.OnlinePlay.Match { if (allowEdit) { - ButtonsContainer.Add(editButton = new PurpleRoundedButton + ButtonsContainer.Add(ChangeSettingsButton = new PurpleRoundedButton { RelativeSizeAxes = Axes.Y, - Size = new Vector2(100, 1), - Text = CommonStrings.ButtonsEdit, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(120, 0.7f), + Text = "Change settings", Action = () => OnEdit?.Invoke() }); } @@ -72,8 +74,8 @@ namespace osu.Game.Screens.OnlinePlay.Match private void updateRoomHost() { - if (editButton != null) - editButton.Alpha = Room.Host?.Equals(api.LocalUser.Value) == true ? 1 : 0; + if (ChangeSettingsButton != null) + ChangeSettingsButton.Alpha = Room.Host?.Equals(api.LocalUser.Value) == true ? 1 : 0; } protected override UpdateableBeatmapBackgroundSprite CreateBackground() => base.CreateBackground().With(d => diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index ffea3878fa..f924ff6980 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -18,6 +17,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -27,6 +27,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Utils; using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Match @@ -49,7 +50,18 @@ namespace osu.Game.Screens.OnlinePlay.Match /// A container that provides controls for selection of user mods. /// This will be shown/hidden automatically when applicable. /// - protected Drawable? UserModsSection; + protected Drawable UserModsSection = null!; + + /// + /// A container that provides controls for selection of the user style. + /// This will be shown/hidden automatically when applicable. + /// + protected Drawable UserStyleSection = null!; + + /// + /// A container that will display the user's style. + /// + protected Container UserStyleDisplayContainer = null!; private Sample? sampleStart; @@ -71,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Match protected RulesetStore Rulesets { get; private set; } = null!; [Resolved] - private IAPIProvider api { get; set; } = null!; + protected IAPIProvider API { get; private set; } = null!; [Resolved(canBeNull: true)] protected OnlinePlayScreen? ParentScreen { get; private set; } @@ -80,7 +92,7 @@ namespace osu.Game.Screens.OnlinePlay.Match private PreviewTrackManager previewTrackManager { get; set; } = null!; [Resolved(canBeNull: true)] - private IDialogOverlay? dialogOverlay { get; set; } + protected IDialogOverlay? DialogOverlay { get; private set; } [Cached] private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -156,10 +168,15 @@ namespace osu.Game.Screens.OnlinePlay.Match { new Drawable[] { - new DrawableMatchRoom(Room, allowEdit) + new OsuContextMenuContainer { - OnEdit = () => settingsOverlay.Show(), - SelectedItem = SelectedItem + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new DrawableMatchRoom(Room, allowEdit) + { + OnEdit = () => settingsOverlay.Show(), + SelectedItem = SelectedItem + } } }, null, @@ -248,11 +265,11 @@ namespace osu.Game.Screens.OnlinePlay.Match { base.LoadComplete(); - SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); - UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); + SelectedItem.BindValueChanged(_ => updateSpecifics()); + UserMods.BindValueChanged(_ => updateSpecifics()); beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); - beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateSpecifics()); userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay); @@ -282,7 +299,7 @@ namespace osu.Game.Screens.OnlinePlay.Match } } - protected virtual bool IsConnected => api.State.Value == APIState.Online; + protected virtual bool IsConnected => API.State.Value == APIState.Online; public override bool OnBackButton() { @@ -321,7 +338,7 @@ namespace osu.Game.Screens.OnlinePlay.Match public override void OnSuspending(ScreenTransitionEvent e) { // Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state. - updateWorkingBeatmap(); + updateSpecifics(); onLeaving(); base.OnSuspending(e); @@ -330,10 +347,10 @@ namespace osu.Game.Screens.OnlinePlay.Match public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); - updateWorkingBeatmap(); + + updateSpecifics(); + beginHandlingTrack(); - Scheduler.AddOnce(UpdateMods); - Scheduler.AddOnce(updateRuleset); } protected bool ExitConfirmed { get; private set; } @@ -343,7 +360,9 @@ namespace osu.Game.Screens.OnlinePlay.Match if (!ensureExitConfirmed()) return true; - RoomManager?.PartRoom(); + if (Room.RoomID != null) + PartRoom(); + Mods.Value = Array.Empty(); onLeaving(); @@ -351,6 +370,11 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnExiting(e); } + /// + /// Parts from the current room. + /// + protected abstract void PartRoom(); + private bool ensureExitConfirmed() { if (ExitConfirmed) @@ -361,17 +385,17 @@ namespace osu.Game.Screens.OnlinePlay.Match bool hasUnsavedChanges = Room.RoomID == null && Room.Playlist.Count > 0; - if (dialogOverlay == null || !hasUnsavedChanges) + if (DialogOverlay == null || !hasUnsavedChanges) return true; // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + if (DialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) { discardChangesDialog.Flash(); return false; } - dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + DialogOverlay.Push(new ConfirmDiscardChangesDialog(() => { ExitConfirmed = true; settingsOverlay.Hide(); @@ -383,9 +407,13 @@ namespace osu.Game.Screens.OnlinePlay.Match protected void StartPlay() { - if (SelectedItem.Value == null) + if (SelectedItem.Value is not PlaylistItem item) return; + item = item.With( + ruleset: GetGameplayRuleset().OnlineID, + beatmap: new Optional(GetGameplayBeatmap())); + // User may be at song select or otherwise when the host starts gameplay. // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. if (!this.IsCurrentScreen()) @@ -401,7 +429,7 @@ namespace osu.Game.Screens.OnlinePlay.Match // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). var targetScreen = (Screen?)ParentScreen ?? this; - targetScreen.Push(CreateGameplayScreen(SelectedItem.Value)); + targetScreen.Push(CreateGameplayScreen(item)); } /// @@ -411,66 +439,71 @@ namespace osu.Game.Screens.OnlinePlay.Match /// The screen to enter. protected abstract Screen CreateGameplayScreen(PlaylistItem selectedItem); - private void selectedItemChanged() + private void updateSpecifics() { - updateWorkingBeatmap(); - - if (SelectedItem.Value is not PlaylistItem selected) + if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item) return; - var rulesetInstance = Rulesets.GetRuleset(selected.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - var allowedMods = selected.AllowedMods.Select(m => m.ToMod(rulesetInstance)); + var rulesetInstance = GetGameplayRuleset().CreateInstance(); + + Mod[] allowedMods = item.Freestyle + ? rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, Room.Type)).ToArray() + : item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); // Remove any user mods that are no longer allowed. - UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList(); + Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); + if (!newUserMods.SequenceEqual(UserMods.Value)) + UserMods.Value = newUserMods; - UpdateMods(); - updateRuleset(); + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + int beatmapId = GetGameplayBeatmap().OnlineID; + var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == beatmapId); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + UserModsSelectOverlay.Beatmap.Value = Beatmap.Value; - if (!selected.AllowedMods.Any()) + Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray(); + Ruleset.Value = GetGameplayRuleset(); + + if (allowedMods.Length > 0) { - UserModsSection?.Hide(); - UserModsSelectOverlay.Hide(); - UserModsSelectOverlay.IsValidMod = _ => false; + UserModsSection.Show(); + UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); } else { - UserModsSection?.Show(); - UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + UserModsSection.Hide(); + UserModsSelectOverlay.Hide(); + UserModsSelectOverlay.IsValidMod = _ => false; } + + if (item.Freestyle) + { + UserStyleSection.Show(); + + PlaylistItem gameplayItem = SelectedItem.Value.With(ruleset: GetGameplayRuleset().OnlineID, beatmap: new Optional(GetGameplayBeatmap())); + PlaylistItem? currentItem = UserStyleDisplayContainer.SingleOrDefault()?.Item; + + if (gameplayItem.Equals(currentItem)) + return; + + UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true) + { + AllowReordering = false, + AllowEditing = true, + RequestEdit = _ => OpenStyleSelection() + }; + } + else + UserStyleSection.Hide(); } - private void updateWorkingBeatmap() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; + protected virtual APIMod[] GetGameplayMods() => UserMods.Value.Select(m => new APIMod(m)).Concat(SelectedItem.Value!.RequiredMods).ToArray(); - var beatmap = SelectedItem.Value?.Beatmap; + protected virtual RulesetInfo GetGameplayRuleset() => Rulesets.GetRuleset(SelectedItem.Value!.RulesetID)!; - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID); + protected virtual IBeatmapInfo GetGameplayBeatmap() => SelectedItem.Value!.Beatmap; - UserModsSelectOverlay.Beatmap.Value = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - } - - protected virtual void UpdateMods() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; - - var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - Mods.Value = UserMods.Value.Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList(); - } - - private void updateRuleset() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; - - Ruleset.Value = Rulesets.GetRuleset(SelectedItem.Value.RulesetID); - } + protected abstract void OpenStyleSelection(); private void beginHandlingTrack() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index 9a03a131b4..befaf115ae 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -19,6 +19,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved(CanBeNull = true)] private ILocalUserPlayInfo? localUserInfo { get; set; } + protected new ChatTextBox TextBox => base.TextBox!; + private readonly IBindable localUserPlaying = new Bindable(); public override bool PropagatePositionalInputSubTree => localUserPlaying.Value != LocalUserPlayingState.Playing; @@ -58,7 +60,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer localUserPlaying.BindValueChanged(playing => { - // for now let's never hold focus. this avoid misdirected gameplay keys entering chat. + // for now let's never hold focus. this avoids misdirected gameplay keys entering chat. // note that this is done within this callback as it triggers an un-focus as well. TextBox.HoldFocus = false; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 0d90d44496..a91b844900 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Threading; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; -using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osuTK; @@ -23,22 +22,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MatchStartControl : CompositeDrawable { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private IDialogOverlay? dialogOverlay { get; set; } [Resolved] private MultiplayerClient client { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly MultiplayerReadyButton readyButton; private readonly MultiplayerCountdownButton countdownButton; @@ -98,9 +90,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - SelectedItem.BindValueChanged(_ => updateState()); client.RoomUpdated += onRoomUpdated; client.LoadRequested += onLoadRequested; + updateState(); } @@ -214,8 +206,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match readyButton.Enabled.Value = countdownButton.Enabled.Value = client.Room.State != MultiplayerRoomState.Closed - && SelectedItem.Value?.ID == client.Room.Settings.PlaylistItemId - && !client.Room.Playlist.Single(i => i.ID == client.Room.Settings.PlaylistItemId).Expired + && !client.Room.CurrentPlaylistItem.Expired && !operationInProgress.Value; // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index 2b592bd8b9..b3923ddde3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -13,14 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private const float ready_button_width = 600; private const float spectate_button_width = 200; - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); - public MultiplayerMatchFooter() { RelativeSizeAxes = Axes.Both; @@ -36,13 +26,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new MultiplayerSpectateButton { RelativeSizeAxes = Axes.Both, - SelectedItem = selectedItem }, null, new MatchStartControl { RelativeSizeAxes = Axes.Both, - SelectedItem = selectedItem }, null } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 79617f172c..42d240c60e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -29,12 +29,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - protected override OsuButton SubmitButton => settings.ApplyButton; protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; @@ -56,7 +50,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, SettingsApplied = Hide, - SelectedItem = { BindTarget = SelectedItem } }; protected partial class MatchSettings : CompositeDrawable @@ -65,7 +58,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public readonly Bindable SelectedItem = new Bindable(); public Action? SettingsApplied; public OsuTextBox NameField = null!; @@ -86,9 +78,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; - [Resolved] - private IRoomManager manager { get; set; } = null!; - [Resolved] private MultiplayerClient client { get; set; } = null!; @@ -279,7 +268,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT, - SelectedItem = { BindTarget = SelectedItem } }, selectBeatmapButton = new RoundedButton { @@ -377,8 +365,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match updateRoomMaxParticipants(); updateRoomAutoStartDuration(); updateRoomPlaylist(); - - drawablePlaylist.Items.BindCollectionChanged((_, __) => room.Playlist = drawablePlaylist.Items.ToArray()); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -456,7 +442,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (!ApplyButton.Enabled.Value) return; - hideError(); + ErrorText.FadeOut(50); Debug.Assert(applyingSettingsOperation == null); applyingSettingsOperation = ongoingOperationTracker.BeginOperation(); @@ -475,32 +461,32 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) - onSuccess(room); + onSuccess(); else - onError(t.Exception?.AsSingular().Message ?? "Error changing settings."); + onError(t.Exception, "Error changing settings"); })); } else { room.Name = NameField.Text; + room.Password = PasswordTextBox.Text; room.Type = TypePicker.Current.Value; - room.Password = PasswordTextBox.Current.Value; room.QueueMode = QueueModeDropdown.Current.Value; room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); room.AutoSkip = AutoSkipCheckbox.Current.Value; + room.Playlist = drawablePlaylist.Items.ToArray(); - if (int.TryParse(MaxParticipantsField.Text, out int max)) - room.MaxParticipants = max; - else - room.MaxParticipants = null; - - manager.CreateRoom(room, onSuccess, onError); + client.CreateRoom(room).ContinueWith(t => Schedule(() => + { + if (t.IsCompletedSuccessfully) + onSuccess(); + else + onError(t.Exception, "Error creating room"); + })); } } - private void hideError() => ErrorText.FadeOut(50); - - private void onSuccess(Room room) => Schedule(() => + private void onSuccess() => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -510,28 +496,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation = null; }); - private void onError(string text) => Schedule(() => + private void onError(Exception? exception, string description) { - Debug.Assert(applyingSettingsOperation != null); + if (exception is AggregateException aggregateException) + exception = aggregateException.AsSingular(); - // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. - const string not_found_prefix = "beatmaps not found:"; + string message = exception?.GetHubExceptionMessage() ?? $"{description} ({exception?.Message})"; - if (text.StartsWith(not_found_prefix, StringComparison.Ordinal)) + Schedule(() => { - ErrorText.Text = "The selected beatmap is not available online."; - SelectedItem.Value?.MarkInvalid(); - } - else - { - ErrorText.Text = text; - } + Debug.Assert(applyingSettingsOperation != null); - ErrorText.FadeIn(50); + // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. + const string not_found_prefix = "beatmaps not found:"; - applyingSettingsOperation.Dispose(); - applyingSettingsOperation = null; - }); + if (message.StartsWith(not_found_prefix, StringComparison.Ordinal)) + ErrorText.Text = "The selected beatmap is not available online."; + else + ErrorText.Text = message; + + ErrorText.FadeIn(50); + + applyingSettingsOperation.Dispose(); + applyingSettingsOperation = null; + }); + } protected override void Dispose(bool isDisposing) { @@ -568,6 +557,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Description("Off")] Off = 0, + [Description("10 seconds")] + Seconds10 = 10, + [Description("30 seconds")] Seconds30 = 30, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 3186cf89a4..13abe7bb14 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -21,12 +21,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MultiplayerSpectateButton : CompositeDrawable { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; @@ -36,7 +30,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerClient client { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly RoundedButton button; private IBindable operationInProgress = null!; @@ -75,7 +68,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true); client.RoomUpdated += onRoomUpdated; updateState(); } @@ -121,11 +113,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void checkForAutomaticDownload() { - PlaylistItem? item = SelectedItem.Value; - downloadCheckCancellation?.Cancel(); - if (item == null) + if (client.Room == null) return; if (!automaticallyDownload.Value) @@ -140,10 +130,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (client.LocalUser?.State != MultiplayerUserState.Spectating) return; + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. beatmapLookupCache - .GetBeatmapAsync(item.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) .ContinueWith(resolved => Schedule(() => { var beatmapSet = resolved.GetResultSafely()?.BeatmapSet; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs index e5d94c5358..a7f3e17efa 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -32,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override void LoadComplete() { base.LoadComplete(); - QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Up next ({QueueItems.Count})" : "Up next", true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index bf316bb3da..0b06a16d98 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -8,7 +8,6 @@ using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -29,11 +28,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onRoomUpdated() { - if (client.Room == null) + if (client.Room == null || client.LocalUser == null) return; - Debug.Assert(client.LocalUser != null); - // If the user exits gameplay before score submission completes, we'll transition to idle when results has been prepared. if (client.LocalUser.State == MultiplayerUserState.Results && this.IsCurrentScreen()) transitionFromResults(); @@ -63,11 +60,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnResuming(e); - if (client.Room == null) + if (client.Room == null || client.LocalUser == null) return; - Debug.Assert(client.LocalUser != null); - if (!(e.Last is MultiplayerPlayerLoader playerLoader)) return; @@ -97,8 +92,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override string ScreenTitle => "Multiplayer"; - protected override RoomManager CreateRoomManager() => new MultiplayerRoomManager(); - protected override LoungeSubScreen CreateLounge() => new MultiplayerLoungeSubScreen(); public void Join(Room room, string? password) => Schedule(() => Lounge.Join(room, password)); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 50358ea9d3..51c135f042 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; -using osu.Framework.Screens; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; @@ -15,10 +13,8 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; -using osu.Game.Screens.OnlinePlay.Match; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -31,23 +27,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private MultiplayerClient client { get; set; } = null!; private Dropdown roomAccessTypeDropdown = null!; - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - - // Upon having left a room, we don't know whether we were the only participant, and whether the room is now closed as a result of leaving it. - // To work around this, temporarily remove the room and trigger an immediate listing poll. - if (e.Last is MultiplayerMatchSubScreen match) - { - RoomManager?.RemoveRoom(match.Room); - ListingPollingComponent.PollImmediately(); - } - } + private OsuCheckbox showInProgress = null!; protected override IEnumerable CreateFilterControls() { - roomAccessTypeDropdown = new SlimEnumDropdown + foreach (var control in base.CreateFilterControls()) + yield return control; + + yield return roomAccessTypeDropdown = new SlimEnumDropdown { RelativeSizeAxes = Axes.None, Current = Config.GetBindable(OsuSetting.MultiplayerRoomFilter), @@ -56,7 +43,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer roomAccessTypeDropdown.Current.BindValueChanged(_ => UpdateFilter()); - return base.CreateFilterControls().Append(roomAccessTypeDropdown); + yield return showInProgress = new OsuCheckbox + { + LabelText = "Show in-progress rooms", + RelativeSizeAxes = Axes.None, + Width = 220, + Padding = new MarginPadding { Vertical = 5, }, + Current = Config.GetBindable(OsuSetting.MultiplayerShowInProgressFilter), + }; + + showInProgress.Current.BindValueChanged(_ => UpdateFilter()); + StatusDropdown.Current.BindValueChanged(_ => showInProgress.Alpha = StatusDropdown.Current.Value == RoomModeFilter.Open ? 1 : 0, true); } protected override FilterCriteria CreateFilterCriteria() @@ -64,6 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer var criteria = base.CreateFilterCriteria(); criteria.Category = @"realtime"; criteria.Permissions = roomAccessTypeDropdown.Current.Value; + criteria.Status = showInProgress.Current.Value && criteria.Mode == RoomModeFilter.Open ? null : RoomStatusFilter.Idle; return criteria; } @@ -75,9 +73,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Type = MatchType.HeadToHead, }; - protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); + protected override OnlinePlaySubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); - protected override ListingPollingComponent CreatePollingComponent() => new MultiplayerListingPollingComponent(); + protected override void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure) + { + client.JoinRoom(room, password).ContinueWith(result => + { + if (result.IsCompletedSuccessfully) + onSuccess(room); + else + { + Exception? exception = result.Exception?.AsSingular(); + + if (exception?.GetHubExceptionMessage() is string message) + onFailure(message); + else + onFailure($"Failed to join multiplayer room: {exception?.Message}"); + } + }); + } + + public override void Close(Room room) + => throw new NotSupportedException("Cannot close multiplayer rooms."); protected override void OpenNewRoom(Room room) { @@ -89,37 +106,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.OpenNewRoom(room); } - - private partial class MultiplayerListingPollingComponent : ListingPollingComponent - { - [Resolved] - private MultiplayerClient client { get; set; } = null!; - - private readonly IBindable isConnected = new Bindable(); - - [BackgroundDependencyLoader] - private void load() - { - isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(_ => Scheduler.AddOnce(poll), true); - } - - private void poll() - { - if (isConnected.Value && IsLoaded) - PollImmediately(); - } - - protected override Task Poll() - { - if (!isConnected.Value) - return Task.CompletedTask; - - if (client.Room != null) - return Task.CompletedTask; - - return base.Poll(); - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs new file mode 100644 index 0000000000..0c04c2712c --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerMatchFreestyleSelect : OnlinePlayFreestyleSelect + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private OngoingOperationTracker operationTracker { get; set; } = null!; + + private readonly IBindable operationInProgress = new Bindable(); + + private LoadingLayer loadingLayer = null!; + private IDisposable? selectionOperation; + + public MultiplayerMatchFreestyleSelect(Room room, PlaylistItem item) + : base(room, item) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(loadingLayer = new LoadingLayer(true)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + operationInProgress.BindTo(operationTracker.InProgress); + operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true); + } + + private void updateLoadingLayer() + { + if (operationInProgress.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); + } + + protected override bool OnStart() + { + if (operationInProgress.Value) + { + Logger.Log($"{nameof(OnStart)} aborted due to {nameof(operationInProgress)}"); + return false; + } + + selectionOperation = operationTracker.BeginOperation(); + + client.ChangeUserStyle(Beatmap.Value.BeatmapInfo.OnlineID, Ruleset.Value.OnlineID) + .FireAndForget(onSuccess: () => + { + selectionOperation.Dispose(); + + Schedule(() => + { + // If an error or server side trigger occurred this screen may have already exited by external means. + if (this.IsCurrentScreen()) + this.Exit(); + }); + }, onError: _ => + { + selectionOperation.Dispose(); + + Schedule(() => + { + Carousel.AllowSelection = true; + }); + }); + + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 4e03c19095..7328e01026 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -11,7 +11,6 @@ using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -86,7 +85,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer BeatmapChecksum = item.Beatmap.MD5Hash, RulesetID = item.RulesetID, RequiredMods = item.RequiredMods.ToArray(), - AllowedMods = item.AllowedMods.ToArray() + AllowedMods = item.AllowedMods.ToArray(), + Freestyle = item.Freestyle }; Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); @@ -121,9 +121,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); - - protected override bool IsValidMod(Mod mod) => base.IsValidMod(mod) && mod.ValidForMultiplayer; - - protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && mod.ValidForMultiplayerAsFreeMod; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index edc45dbf7c..2b3243e01d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -16,6 +16,8 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -96,7 +98,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { new Drawable?[] { - // Participants column new GridContainer { RelativeSizeAxes = Axes.Both, @@ -116,15 +117,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } }, - // Spacer null, - // Beatmap column new GridContainer { RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, Content = new[] { - new Drawable[] { new OverlinedHeader("Beatmap") }, + new Drawable[] { new OverlinedHeader("Beatmap queue") }, new Drawable[] { addItemButton = new AddItemButton @@ -168,6 +176,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Width = 90, + Height = 30, Text = "Select", Action = ShowUserModSelect, }, @@ -181,21 +190,30 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } }, } + } + }, + new[] + { + UserStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + UserStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } }, }, }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } }, - // Spacer null, - // Main right column new GridContainer { RelativeSizeAxes = Axes.Both, @@ -228,26 +246,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); } - protected override Drawable CreateFooter() => new MultiplayerMatchFooter + protected override void OpenStyleSelection() { - SelectedItem = SelectedItem - }; - - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room) - { - SelectedItem = SelectedItem - }; - - protected override void UpdateMods() - { - if (SelectedItem.Value == null || client.LocalUser == null || !this.IsCurrentScreen()) + if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item) return; - // update local mods based on room's reported status for the local user (omitting the base call implementation). - // this makes the server authoritative, and avoids the local user potentially setting mods that the server is not aware of (ie. if the match was started during the selection being changed). - var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - Mods.Value = client.LocalUser.Mods.Select(m => m.ToMod(rulesetInstance)).Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList(); + this.Push(new MultiplayerMatchFreestyleSelect(Room, item)); + } + + protected override Drawable CreateFooter() => new MultiplayerMatchFooter(); + + protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room); + + protected override APIMod[] GetGameplayMods() + { + // Using the room's reported status makes the server authoritative. + return client.LocalUser?.Mods != null ? client.LocalUser.Mods.Concat(SelectedItem.Value!.RequiredMods).ToArray() : base.GetGameplayMods(); + } + + protected override RulesetInfo GetGameplayRuleset() + { + // Using the room's reported status makes the server authoritative. + return client.LocalUser?.RulesetId != null ? Rulesets.GetRuleset(client.LocalUser.RulesetId.Value)! : base.GetGameplayRuleset(); + } + + protected override IBeatmapInfo GetGameplayBeatmap() + { + // Using the room's reported status makes the server authoritative. + return client.LocalUser?.BeatmapId != null ? new APIBeatmap { OnlineID = client.LocalUser.BeatmapId.Value } : base.GetGameplayBeatmap(); } [Resolved(canBeNull: true)] @@ -281,6 +307,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return base.OnExiting(e); } + protected override void PartRoom() => client.LeaveRoom(); + private ModSettingChangeTracker? modSettingChangeTracker; private ScheduledDelegate? debouncedModSettingsUpdate; @@ -349,23 +377,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - updateCurrentItem(); + SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); addItemButton.Alpha = localUserCanAddItem ? 1 : 0; - Scheduler.AddOnce(UpdateMods); - Activity.Value = new UserActivity.InLobby(Room); } private bool localUserCanAddItem => client.IsHost || Room.QueueMode != QueueMode.HostOnly; - private void updateCurrentItem() - { - Debug.Assert(client.Room != null); - SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); - } - private void handleRoomLost() => Schedule(() => { Logger.Log($"{this} exiting due to loss of room or connection"); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 111b453adb..3d4b46f49e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -198,11 +198,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return multiplayerLeaderboard.TeamScores.Count == 2 ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) { - ShowUserStatistics = true, + IsLocalPlay = true, } : new MultiplayerResultsScreen(score, Room.RoomID.Value, PlaylistItem) { - ShowUserStatistics = true + IsLocalPlay = true, }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index 7eb7f6610e..dd9cb56862 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -18,6 +18,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + private Player? player; public MultiplayerPlayerLoader(Func createPlayer) @@ -39,6 +42,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnPlayerLoaded(); + game?.Window?.Flash(); + multiplayerClient.ChangeState(MultiplayerUserState.Loaded) .ContinueWith(task => failAndBail(task.Exception?.Message ?? "Server error"), TaskContinuationOptions.NotOnRanToCompletion); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs deleted file mode 100644 index e16582a6e1..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Diagnostics; -using osu.Framework.Allocation; -using osu.Framework.Extensions.ExceptionExtensions; -using osu.Framework.Logging; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Screens.OnlinePlay.Components; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer -{ - public partial class MultiplayerRoomManager : RoomManager - { - [Resolved] - private MultiplayerClient multiplayerClient { get; set; } = null!; - - public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password, onSuccess, onError), onError); - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - if (!multiplayerClient.IsConnected.Value) - { - onError?.Invoke("Not currently connected to the multiplayer server."); - return; - } - - // this is done here as a pre-check to avoid clicking on already closed rooms in the lounge from triggering a server join. - // should probably be done at a higher level, but due to the current structure of things this is the easiest place for now. - if (room.Status is RoomStatusEnded) - { - onError?.Invoke("Cannot join an ended room."); - return; - } - - base.JoinRoom(room, password, r => joinMultiplayerRoom(r, password, onSuccess, onError), onError); - } - - public override void PartRoom() - { - if (JoinedRoom.Value == null) - return; - - base.PartRoom(); - multiplayerClient.LeaveRoom(); - } - - private void joinMultiplayerRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) - { - Debug.Assert(room.RoomID != null); - - multiplayerClient.JoinRoom(room, password).ContinueWith(t => - { - if (t.IsCompletedSuccessfully) - Schedule(() => onSuccess?.Invoke(room)); - else if (t.IsFaulted) - { - const string message = "Failed to join multiplayer room."; - - if (t.Exception != null) - Logger.Error(t.Exception, message); - - PartRoom(); - Schedule(() => onError?.Invoke(t.Exception?.AsSingular().Message ?? message)); - } - }); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs index d53e485c86..cdf4e96bad 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -20,7 +19,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private Sample? userJoinedSample; private Sample? userLeftSample; private Sample? userKickedSample; - private MultiplayerRoomUser? host; [BackgroundDependencyLoader] private void load(AudioManager audio) @@ -35,25 +33,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - client.RoomUpdated += onRoomUpdated; client.UserJoined += onUserJoined; client.UserLeft += onUserLeft; client.UserKicked += onUserKicked; - updateState(); - } - - private void onRoomUpdated() => Scheduler.AddOnce(updateState); - - private void updateState() - { - if (EqualityComparer.Default.Equals(host, client.Room?.Host)) - return; - - // only play sound when the host changes from an already-existing host. - if (host != null) - Scheduler.AddOnce(() => hostChangedSample?.Play()); - - host = client.Room?.Host; + client.HostChanged += onHostChanged; } private void onUserJoined(MultiplayerRoomUser user) @@ -65,16 +48,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(() => userKickedSample?.Play()); + private void onHostChanged(MultiplayerRoomUser? host) + { + if (host != null) + Scheduler.AddOnce(() => hostChangedSample?.Play()); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (client.IsNotNull()) { - client.RoomUpdated -= onRoomUpdated; client.UserJoined -= onUserJoined; client.UserLeft -= onUserLeft; client.UserKicked -= onUserKicked; + client.HostChanged -= onHostChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 7e42b18240..0cedfb9909 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; @@ -14,6 +16,9 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Logging; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -47,6 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private SpriteIcon crown = null!; private OsuSpriteText userRankText = null!; + private StyleDisplayIcon userStyleDisplay = null!; private ModDisplay userModsDisplay = null!; private StateDisplay userStateDisplay = null!; @@ -134,6 +140,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Size = new Vector2(28, 20), CountryCode = user?.CountryCode ?? default }, + new UpdateableTeamFlag(user?.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new OsuSpriteText { Anchor = Anchor.CentreLeft, @@ -149,16 +161,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } }, - new Container + new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Right = 70 }, - Child = userModsDisplay = new ModDisplay + Spacing = new Vector2(2), + Children = new Drawable[] { - Scale = new Vector2(0.5f), - ExpansionMode = ExpansionMode.AlwaysContracted, + userStyleDisplay = new StyleDisplayIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + userModsDisplay = new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.5f), + ExpansionMode = ExpansionMode.AlwaysContracted, + } } }, userStateDisplay = new StateDisplay @@ -199,28 +222,40 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; - MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem(); - Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; + if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) + { + int userBeatmapId = User.BeatmapId ?? currentItem.BeatmapID; + int userRulesetId = User.RulesetId ?? currentItem.RulesetID; + Ruleset? userRuleset = rulesets.GetRuleset(userRulesetId)?.CreateInstance(); - int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; - userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; + int? currentModeRank = userRuleset == null ? null : User.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; + userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; + + if (userBeatmapId == currentItem.BeatmapID && userRulesetId == currentItem.RulesetID) + userStyleDisplay.Style = null; + else + userStyleDisplay.Style = (userBeatmapId, userRulesetId); + + // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 + // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : User.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + } userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); - if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating)) + if (User.BeatmapAvailability.State == DownloadState.LocallyAvailable && User.State != MultiplayerUserState.Spectating) + { userModsDisplay.FadeIn(fade_time); + userStyleDisplay.FadeIn(fade_time); + } else + { userModsDisplay.FadeOut(fade_time); + userStyleDisplay.FadeOut(fade_time); + } kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0; crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0; - - // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 - // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => - { - userModsDisplay.Current.Value = ruleset != null ? User.Mods.Select(m => m.ToMod(ruleset)).ToList() : Array.Empty(); - }); } public MenuItem[]? ContextMenuItems @@ -284,5 +319,81 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants IconHoverColour = colours.Red; } } + + private partial class StyleDisplayIcon : CompositeComponent + { + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public StyleDisplayIcon() + { + AutoSizeAxes = Axes.Both; + } + + private (int beatmap, int ruleset)? style; + + public (int beatmap, int ruleset)? Style + { + get => style; + set + { + if (style == value) + return; + + style = value; + Scheduler.Add(refresh); + } + } + + private CancellationTokenSource? cancellationSource; + + private void refresh() + { + cancellationSource?.Cancel(); + cancellationSource?.Dispose(); + cancellationSource = null; + + if (Style == null) + { + ClearInternal(); + return; + } + + cancellationSource = new CancellationTokenSource(); + CancellationToken token = cancellationSource.Token; + + int localBeatmap = Style.Value.beatmap; + int localRuleset = Style.Value.ruleset; + + Task.Run(async () => + { + try + { + var beatmap = await beatmapLookupCache.GetBeatmapAsync(localBeatmap, token).ConfigureAwait(false); + if (beatmap == null) + return; + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + InternalChild = new DifficultyIcon(beatmap, rulesets.GetRuleset(localRuleset)) + { + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + }; + }); + } + catch (Exception e) + { + Logger.Log($"Error while populating participant style icon {e}"); + } + }, token); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs index c240bbea0c..3cf1661c8d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using osu.Game.Online.API; +using System.Threading.Tasks; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -23,8 +21,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Scheduler.AddDelayed(() => StatisticsPanel.ToggleVisibility(), 1000); } - protected override APIRequest? FetchScores(Action> scoresCallback) => null; + protected override Task FetchScores() => Task.FromResult([]); - protected override APIRequest? FetchNextPage(int direction, Action> scoresCallback) => null; + protected override Task FetchNextPage(int direction) => Task.FromResult([]); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index cb00763e6b..33c3c60ed3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -126,7 +126,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate syncManager = new SpectatorSyncManager(masterClockContainer) { ReadyToStart = performInitialSeek, - } + }, + new PlayerSettingsOverlay() }; for (int i = 0; i < Users.Count; i++) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 1b03452df7..b8f0a67a46 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -53,13 +55,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public Score? Score { get; private set; } [Resolved] - private IBindable beatmap { get; set; } = null!; + private BeatmapManager beatmapManager { get; set; } = null!; private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly BindableDouble volumeAdjustment = new BindableDouble(); private readonly Container gameplayContent; private readonly LoadingLayer loadingLayer; private OsuScreenStack? stack; + private Track? loadedTrack; public PlayerArea(int userId, SpectatorPlayerClock clock) { @@ -89,7 +92,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Score = score; - gameplayContent.Child = new PlayerIsolationContainer(beatmap.Value, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + // Required for freestyle, where each player may be playing a different beatmap. + var workingBeatmap = beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo); + + // Required to avoid crashes, but we really don't want to be doing this if we can avoid it. + // If we get to fixing this, we will want to investigate every access to `Track` in gameplay. + if (!workingBeatmap.TrackLoaded) + loadedTrack = workingBeatmap.LoadTrack(); + + gameplayContent.Child = new PlayerIsolationContainer(workingBeatmap, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, Child = stack = new OsuScreenStack @@ -118,8 +129,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate get => mute; set { + if (mute == value) + return; + mute = value; volumeAdjustment.Value = value ? 0 : 1; + Logger.Log($"{(mute ? "muting" : "unmuting")} player {UserId}"); } } @@ -127,18 +142,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public override bool PropagatePositionalInputSubTree => false; public override bool PropagateNonPositionalInputSubTree => false; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + loadedTrack?.Dispose(); + } + /// /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings). /// private partial class PlayerIsolationContainer : Container { [Cached] + [Cached(typeof(IBindable))] private readonly Bindable ruleset = new Bindable(); [Cached] + [Cached(typeof(IBindable))] private readonly Bindable beatmap = new Bindable(); [Cached] + [Cached(typeof(IBindable>))] private readonly Bindable> mods = new Bindable>(); public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList mods) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs new file mode 100644 index 0000000000..4844d096ce --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osu.Game.Users; + +namespace osu.Game.Screens.OnlinePlay +{ + public abstract partial class OnlinePlayFreestyleSelect : SongSelect, IOnlinePlaySubScreen, IHandlePresentBeatmap + { + public string ShortTitle => "style selection"; + + public override string Title => ShortTitle.Humanize(); + + public override bool AllowEditing => false; + + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); + + private readonly Room room; + private readonly PlaylistItem item; + + protected OnlinePlayFreestyleSelect(Room room, PlaylistItem item) + { + this.room = room; + this.item = item; + + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + } + + [BackgroundDependencyLoader] + private void load() + { + LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; + } + + protected override FilterControl CreateFilterControl() => new DifficultySelectFilterControl(item); + + protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() + { + // Required to create the drawable components. + base.CreateSongSelectFooterButtons(); + return Enumerable.Empty<(FooterButton, OverlayContainer?)>(); + } + + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + // This screen cannot present beatmaps. + } + + private partial class DifficultySelectFilterControl : FilterControl + { + private readonly PlaylistItem item; + private double itemLength; + private int beatmapSetId; + + public DifficultySelectFilterControl(PlaylistItem item) + { + this.item = item; + } + + [BackgroundDependencyLoader] + private void load(RealmAccess realm) + { + realm.Run(r => + { + int beatmapId = item.Beatmap.OnlineID; + BeatmapInfo? beatmap = r.All().FirstOrDefault(b => b.OnlineID == beatmapId); + + itemLength = beatmap?.Length ?? 0; + beatmapSetId = beatmap?.BeatmapSet?.OnlineID ?? 0; + }); + } + + public override FilterCriteria CreateCriteria() + { + var criteria = base.CreateCriteria(); + + // Must be from the same set as the playlist item. + criteria.BeatmapSetId = beatmapSetId; + criteria.HasOnlineID = true; + + // Must be within 30s of the playlist item. + criteria.Length.Min = itemLength - 30000; + criteria.Length.Max = itemLength + 30000; + criteria.Length.IsLowerInclusive = true; + criteria.Length.IsUpperInclusive = true; + + return criteria; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index cc6a4e09e1..812e42479b 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Screens.Menu; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Users; @@ -36,9 +35,6 @@ namespace osu.Game.Screens.OnlinePlay private readonly ScreenStack screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both }; private OnlinePlayScreenWaveContainer waves = null!; - [Cached(Type = typeof(IRoomManager))] - protected RoomManager RoomManager { get; private set; } - [Cached] private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); @@ -51,8 +47,6 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; - - RoomManager = CreateRoomManager(); } private readonly IBindable apiState = new Bindable(); @@ -67,7 +61,6 @@ namespace osu.Game.Screens.OnlinePlay { screenStack, new Header(ScreenTitle, screenStack), - RoomManager, ongoingOperationTracker, } }; @@ -165,8 +158,6 @@ namespace osu.Game.Screens.OnlinePlay subScreen.Exit(); } - RoomManager.PartRoom(); - waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); @@ -180,7 +171,7 @@ namespace osu.Game.Screens.OnlinePlay if (!(screenStack.CurrentScreen is IOnlinePlaySubScreen onlineSubScreen)) return false; - if (((Drawable)onlineSubScreen).IsLoaded && onlineSubScreen.AllowBackButton && onlineSubScreen.OnBackButton()) + if (((Drawable)onlineSubScreen).IsLoaded && onlineSubScreen.AllowUserExit && onlineSubScreen.OnBackButton()) return true; if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) @@ -224,8 +215,6 @@ namespace osu.Game.Screens.OnlinePlay protected abstract string ScreenTitle { get; } - protected virtual RoomManager CreateRoomManager() => new RoomManager(); - protected abstract LoungeSubScreen CreateLounge(); ScreenStack IHasSubScreenStack.SubScreenStack => screenStack; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index f6b6dfd3ab..9bedecc221 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay { @@ -41,10 +42,12 @@ namespace osu.Game.Screens.OnlinePlay protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); + protected readonly Bindable Freestyle = new Bindable(true); private readonly Room room; private readonly PlaylistItem? initialItem; - private readonly FreeModSelectOverlay freeModSelectOverlay; + private readonly FreeModSelectOverlay freeModSelect; + private FooterButton freeModsFooterButton = null!; private IDisposable? freeModSelectOverlayRegistration; @@ -61,10 +64,10 @@ namespace osu.Game.Screens.OnlinePlay Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - freeModSelectOverlay = new FreeModSelectOverlay + freeModSelect = new FreeModSelectOverlay { SelectedMods = { BindTarget = FreeMods }, - IsValidMod = IsValidFreeMod, + IsValidMod = isValidFreeMod, }; } @@ -72,7 +75,7 @@ namespace osu.Game.Screens.OnlinePlay private void load() { LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - LoadComponent(freeModSelectOverlay); + LoadComponent(freeModSelect); } protected override void LoadComplete() @@ -108,20 +111,44 @@ namespace osu.Game.Screens.OnlinePlay Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); } + + Freestyle.Value = initialItem.Freestyle; } Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); + Freestyle.BindValueChanged(onFreestyleChanged, true); - freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelectOverlay); + freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); + } + + private void onFreestyleChanged(ValueChangedEvent enabled) + { + if (enabled.NewValue) + { + freeModsFooterButton.Enabled.Value = false; + freeModsFooterButton.Enabled.Value = false; + ModsFooterButton.Enabled.Value = false; + + ModSelect.Hide(); + freeModSelect.Hide(); + + Mods.Value = []; + FreeMods.Value = []; + } + else + { + freeModsFooterButton.Enabled.Value = true; + ModsFooterButton.Enabled.Value = true; + } } private void onModsChanged(ValueChangedEvent> mods) { - FreeMods.Value = FreeMods.Value.Where(checkCompatibleFreeMod).ToList(); + FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToList(); // Reset the validity delegate to update the overlay's display. - freeModSelectOverlay.IsValidMod = IsValidFreeMod; + freeModSelect.IsValidMod = isValidFreeMod; } private void onRulesetChanged(ValueChangedEvent ruleset) @@ -135,7 +162,8 @@ namespace osu.Game.Screens.OnlinePlay { RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = Freestyle.Value }; return SelectItem(item); @@ -150,9 +178,9 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnBackButton() { - if (freeModSelectOverlay.State.Value == Visibility.Visible) + if (freeModSelect.State.Value == Visibility.Visible) { - freeModSelectOverlay.Hide(); + freeModSelect.Hide(); return true; } @@ -161,21 +189,33 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - freeModSelectOverlay.Hide(); + freeModSelect.Hide(); return base.OnExiting(e); } protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = IsValidMod + IsValidMod = isValidMod }; - protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() + protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() { var baseButtons = base.CreateSongSelectFooterButtons().ToList(); - var freeModsButton = new FooterButtonFreeMods(freeModSelectOverlay) { Current = FreeMods }; - baseButtons.Insert(baseButtons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (freeModsButton, freeModSelectOverlay)); + baseButtons.Single(i => i.button is FooterButtonMods).button.TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + + baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] + { + (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) + { + FreeMods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } + }, null), + (new FooterButtonFreestyle + { + Freestyle = { BindTarget = Freestyle } + }, null) + }); return baseButtons; } @@ -185,18 +225,18 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && ModUtils.FlattenMod(mod).All(m => m.UserPlayable); + private bool isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type); /// /// Checks whether a given is valid for per-player free-mod selection. /// /// The to check. /// Whether is a selectable free-mod. - protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod) && checkCompatibleFreeMod(mod); - - private bool checkCompatibleFreeMod(Mod mod) - => Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must not be contained in the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); // Mod must be compatible with all the required mods. + private bool isValidFreeMod(Mod mod) => ModUtils.IsValidFreeModForMatchType(mod, room.Type) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs index fa1ee004c9..9b35a794a3 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -15,9 +14,6 @@ namespace osu.Game.Screens.OnlinePlay protected sealed override bool PlayExitSound => false; - [Resolved] - protected IRoomManager? RoomManager { get; private set; } - protected OnlinePlaySubScreen() { Anchor = Anchor.Centre; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs index 6695c97508..79baa490ac 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs @@ -1,27 +1,74 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay { public partial class OnlinePlaySubScreenStack : OsuScreenStack { + private OsuScreenDependencies dependencies = null!; + + // Note - these bindables must be stored to fields of this component to be correctly unbound on disposal. + private Bindable beatmap = null!; + private Bindable ruleset = null!; + private Bindable> mods = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + // Bindables are leased by the OnlinePlayScreen, but pulled locally in order to not rely on screen load timings. + // They will all be initially enabled while there is no screen in this stack. + dependencies = new OsuScreenDependencies(true, base.CreateChildDependencies(parent)) + { + Beatmap = { Disabled = false }, + Ruleset = { Disabled = false }, + Mods = { Disabled = false } + }; + + beatmap = dependencies.Beatmap; + ruleset = dependencies.Ruleset; + mods = dependencies.Mods; + + return dependencies; + } + protected override void ScreenChanged(IScreen prev, IScreen? next) { base.ScreenChanged(prev, next); - // because this is a screen stack within a screen stack, let's manually handle disabled changes to simplify things. - var osuScreen = next as OsuScreen; + if (next is not OsuScreen osuNext) + throw new InvalidOperationException("There must always be an online play subscreen."); - Debug.Assert(osuScreen != null); + // See: OnlinePlayScreen.DisallowExternalBeatmapRulesetChanges. + // + // Bindable leases are held by the OnlinePlayScreen and NOT by the subscreens, + // because PlayerLoader needs to resolve LeasedBindables to function correctly. + // + // An unfortunate consequence of this is we need to manually control bindable + // enablement depending on what effect the subscreens want. + // + // This is a two-part process... - bool disallowChanges = osuScreen.DisallowExternalBeatmapRulesetChanges; + // First, emulate the behaviour of DisallowExternalBeatmapRulesetChanges to disable toolbar buttons. + beatmap.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; + ruleset.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; + mods.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; - osuScreen.Beatmap.Disabled = disallowChanges; - osuScreen.Ruleset.Disabled = disallowChanges; - osuScreen.Mods.Disabled = disallowChanges; + // Second, when an OsuScreen is exited with DisallowExternalBeatmapRulesetChanges=true, leased bindables + // are normally returned which reverts the mod and ruleset bindables to their original states. + // + // The exact behaiour of the revert is awkward to emulate, but we particularly care about resetting mods + // when returning to the lounge so that they don't stick around if the user then goes to create a new room. + if (next is LoungeSubScreen) + mods.Value = []; } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..47629981f1 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using Realms; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class AddPlaylistToCollectionButton : RoundedButton + { + private readonly Room room; + + private IDisposable? beatmapSubscription; + private IDisposable? collectionSubscription; + + private Live? collection; + private HashSet localBeatmapHashes = new HashSet(); + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved(canBeNull: true)] + private INotificationOverlay? notifications { get; set; } + + public AddPlaylistToCollectionButton(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + Action = () => + { + if (room.Playlist.Count == 0) + return; + + int countBefore = 0; + int countAfter = 0; + + Text = "Updating collection..."; + Enabled.Value = false; + + realm.WriteAsync(r => + { + var beatmaps = getBeatmapsForPlaylist(r).ToArray(); + var c = getCollectionsForPlaylist(r).FirstOrDefault() + ?? r.Add(new BeatmapCollection(room.Name)); + + countBefore = c.BeatmapMD5Hashes.Count; + + foreach (var item in beatmaps) + { + if (!c.BeatmapMD5Hashes.Contains(item.MD5Hash)) + c.BeatmapMD5Hashes.Add(item.MD5Hash); + } + + countAfter = c.BeatmapMD5Hashes.Count; + }).ContinueWith(_ => Schedule(() => + { + if (countBefore == 0) + notifications?.Post(new SimpleNotification { Text = $"Created new collection \"{room.Name}\" with {countAfter} beatmaps." }); + else + notifications?.Post(new SimpleNotification { Text = $"Added {countAfter - countBefore} beatmaps to collection \"{room.Name}\"." }); + })); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // will be updated via updateButtonState() when ready. + Enabled.Value = false; + + if (room.Playlist.Count == 0) + return; + + beatmapSubscription = realm.RegisterForNotifications(getBeatmapsForPlaylist, (sender, _) => + { + localBeatmapHashes = sender.Select(b => b.MD5Hash).ToHashSet(); + Schedule(updateButtonState); + }); + + collectionSubscription = realm.RegisterForNotifications(getCollectionsForPlaylist, (sender, _) => + { + collection = sender.FirstOrDefault()?.ToLive(realm); + Schedule(updateButtonState); + }); + } + + private void updateButtonState() + { + int countToAdd = getCountToBeAdded(); + + if (collection == null) + Text = $"Create new collection with {countToAdd} beatmaps"; + else if (hasAllItemsInCollection) + Text = "Collection complete!"; + else + Text = $"Add {countToAdd} beatmaps to collection"; + + Enabled.Value = countToAdd > 0; + } + + private int getCountToBeAdded() + { + if (collection == null) + return localBeatmapHashes.Count; + + return collection.PerformRead(c => + { + int count = localBeatmapHashes.Count; + + foreach (string hash in localBeatmapHashes) + { + if (c.BeatmapMD5Hashes.Contains(hash)) + count--; + } + + return count; + }); + } + + private IQueryable getCollectionsForPlaylist(Realm r) => r.All().Where(c => c.Name == room.Name); + + private IQueryable getBeatmapsForPlaylist(Realm r) + { + return r.All().Filter(string.Join(" OR ", room.Playlist.Select(item => $"(OnlineID == {item.Beatmap.OnlineID})").Distinct())); + } + + private bool hasAllItemsInCollection + { + get + { + if (collection == null) + return false; + + return room.Playlist.DistinctBy(i => i.Beatmap.OnlineID).Count() == + collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapSubscription?.Dispose(); + collectionSubscription?.Dispose(); + } + + public override LocalisableString TooltipText + { + get + { + if (Enabled.Value) + return string.Empty; + + if (hasAllItemsInCollection) + return "All beatmaps have been added!"; + + return "Download some beatmaps first."; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs new file mode 100644 index 0000000000..08fed037d3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Online.Rooms; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class ClosePlaylistDialog : DeletionDialog + { + public ClosePlaylistDialog(Room room, Action closeAction) + { + HeaderText = "Are you sure you want to close the following playlist:"; + BodyText = room.Name; + DangerousAction = closeAction; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index 81ae51bd1b..0e539936d8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -5,11 +5,17 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; +using osu.Game.Models; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -38,6 +44,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] protected RulesetStore Rulesets { get; private set; } = null!; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + protected PlaylistItemResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) : base(score) { @@ -76,16 +88,21 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected abstract APIRequest CreateScoreRequest(); - protected sealed override APIRequest FetchScores(Action> scoresCallback) + protected override async Task FetchScores() { // This performs two requests: // 1. A request to show the relevant score (and scores around). // 2. If that fails, a request to index the room starting from the highest score. + var requestTaskSource = new TaskCompletionSource(); var userScoreReq = CreateScoreRequest(); + userScoreReq.Success += requestTaskSource.SetResult; + userScoreReq.Failure += requestTaskSource.SetException; + API.Queue(userScoreReq); - userScoreReq.Success += userScore => + try { + var userScore = await requestTaskSource.Task.ConfigureAwait(false); var allScores = new List { userScore }; // Other scores could have arrived between score submission and entering the results screen. Ensure the local player score position is up to date. @@ -113,98 +130,157 @@ namespace osu.Game.Screens.OnlinePlay.Playlists setPositions(lowerScores, userScore.Position.Value, 1); } - Schedule(() => - { - PerformSuccessCallback(scoresCallback, allScores); - hideLoadingSpinners(); - }); - }; - - // On failure, fallback to a normal index. - userScoreReq.Failure += _ => API.Queue(createIndexRequest(scoresCallback)); - - return userScoreReq; + return await transformScores(allScores).ConfigureAwait(false); + } + catch + { + return await fetchScoresAround().ConfigureAwait(false); + } } - protected override APIRequest? FetchNextPage(int direction, Action> scoresCallback) + protected override async Task FetchNextPage(int direction) { Debug.Assert(direction == 1 || direction == -1); MultiplayerScores? pivot = direction == -1 ? higherScores : lowerScores; - if (pivot?.Cursor == null) - return null; + return []; - if (pivot == higherScores) - LeftSpinner.Show(); - else - RightSpinner.Show(); + Schedule(() => + { + if (pivot == higherScores) + LeftSpinner.Show(); + else + RightSpinner.Show(); + }); - return createIndexRequest(scoresCallback, pivot); + return await fetchScoresAround(pivot).ConfigureAwait(false); } /// /// Creates a with an optional score pivot. /// /// Does not queue the request. - /// The callback to perform with the resulting scores. /// An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score. - /// The indexing . - private APIRequest createIndexRequest(Action> scoresCallback, MultiplayerScores? pivot = null) + private async Task fetchScoresAround(MultiplayerScores? pivot = null) { + var requestTaskSource = new TaskCompletionSource(); var indexReq = pivot != null ? new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID, pivot.Cursor, pivot.Params) : new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID); + indexReq.Success += requestTaskSource.SetResult; + indexReq.Failure += requestTaskSource.SetException; + API.Queue(indexReq); - indexReq.Success += r => + try { + var index = await requestTaskSource.Task.ConfigureAwait(false); + if (pivot == lowerScores) { - lowerScores = r; - setPositions(r, pivot, 1); + lowerScores = index; + setPositions(index, pivot, 1); } else { - higherScores = r; - setPositions(r, pivot, -1); + higherScores = index; + setPositions(index, pivot, -1); + + // when paginating the results, it's possible for the user's score to naturally fall down the rankings. + // unmitigated, this can cause scores at the very top of the rankings to have zero or negative positions + // because the positions are counted backwards from the user's score, which has increased in this case during pagination. + // if this happens, just give the top score the first position. + // note that this isn't 100% correct, but it *is* however the most reliable way to mask the problem. + int smallestPosition = index.Scores.Min(s => s.Position ?? 1); + + if (smallestPosition < 1) + { + int offset = 1 - smallestPosition; + + foreach (var scorePanel in ScorePanelList.GetScorePanels()) + scorePanel.ScorePosition.Value += offset; + + foreach (var score in index.Scores) + score.Position += offset; + } } - Schedule(() => - { - PerformSuccessCallback(scoresCallback, r.Scores, r); - hideLoadingSpinners(r); - }); - }; - - indexReq.Failure += _ => hideLoadingSpinners(pivot); - - return indexReq; + return await transformScores(index.Scores).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to fetch scores (room: {RoomId}, item: {PlaylistItem.ID}): {ex}"); + return []; + } } /// - /// Transforms returned into s, ensure the is put into a sane state, and invokes a given success callback. + /// Transforms returned into s. /// - /// The callback to invoke with the final s. /// The s that were retrieved from s. - /// An optional pivot around which the scores were retrieved. - protected virtual ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + private async Task transformScores(List scores) { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); + int[] allBeatmapIds = scores.Select(s => s.BeatmapId).Distinct().ToArray(); + BeatmapInfo[] localBeatmaps = allBeatmapIds.Select(id => beatmapManager.QueryBeatmap(b => b.OnlineID == id)) + .Where(b => b != null) + .ToArray()!; - // Invoke callback to add the scores. Exclude the score provided to this screen since it's added already. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); + int[] missingBeatmapIds = allBeatmapIds.Except(localBeatmaps.Select(b => b.OnlineID)).ToArray(); + APIBeatmap[] onlineBeatmaps = (await beatmapLookupCache.GetBeatmapsAsync(missingBeatmapIds).ConfigureAwait(false)).Where(b => b != null).ToArray()!; - return scoreInfos; + Dictionary beatmapsById = new Dictionary(); + + foreach (var beatmap in localBeatmaps) + beatmapsById[beatmap.OnlineID] = beatmap; + + foreach (var beatmap in onlineBeatmaps) + { + // Minimal data required to get various components in this screen to display correctly. + beatmapsById[beatmap.OnlineID] = new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(beatmap.Difficulty), + Metadata = + { + Artist = beatmap.Metadata.Artist, + Title = beatmap.Metadata.Title, + Author = new RealmUser + { + Username = beatmap.Metadata.Author.Username, + OnlineID = beatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = beatmap.DifficultyName, + StarRating = beatmap.StarRating, + Length = beatmap.Length, + BPM = beatmap.BPM + }; + } + + // Validate that we have all beatmaps we need. + foreach (int id in allBeatmapIds) + { + if (!beatmapsById.ContainsKey(id)) + { + Logger.Log($"Failed to fetch beatmap {id} to display scores for playlist item {PlaylistItem.ID}"); + beatmapsById[id] = Beatmap.Value.BeatmapInfo; + } + } + + // Exclude the score provided to this screen since it's added already. + return scores + .Where(s => s.ID != Score?.OnlineID) + .Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, beatmapsById[s.BeatmapId])) + .OrderByTotalScore() + .ToArray(); } - private void hideLoadingSpinners(MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - CentreSpinner.Hide(); + base.OnScoresAdded(scores); - if (pivot == lowerScores) - RightSpinner.Hide(); - else if (pivot == higherScores) - LeftSpinner.Hide(); + CentreSpinner.Hide(); + RightSpinner.Hide(); + LeftSpinner.Hide(); } /// @@ -213,7 +289,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The to set positions on. /// The pivot. /// The amount to increment the pivot position by for each in . - private void setPositions(MultiplayerScores scores, MultiplayerScores? pivot, int increment) + private static void setPositions(MultiplayerScores scores, MultiplayerScores? pivot, int increment) => setPositions(scores, pivot?.Scores[^1].Position ?? 0, increment); /// @@ -222,7 +298,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The to set positions on. /// The pivot position. /// The amount to increment the pivot position by for each in . - private void setPositions(MultiplayerScores scores, int pivotPosition, int increment) + private static void setPositions(MultiplayerScores scores, int pivotPosition, int increment) { foreach (var s in scores.Scores) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs index 05c03a4b28..74b12b6d3c 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -31,11 +29,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override APIRequest CreateScoreRequest() => new ShowPlaylistScoreRequest(RoomId, PlaylistItem.ID, scoreId); - protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); - Schedule(() => SelectedScore.Value ??= scoreInfos.SingleOrDefault(s => s.OnlineID == scoreId)); - return scoreInfos; + base.OnScoresAdded(scores); + SelectedScore.Value ??= scores.SingleOrDefault(s => s.OnlineID == scoreId); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs index 5b20496dba..866b094178 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -25,17 +23,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, userId); - protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); + base.OnScoresAdded(scores); - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value ??= scoreInfos.FirstOrDefault(s => s.UserID == userId) ?? scoreInfos.FirstOrDefault(); - }); - - return scoreInfos; + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value ??= scores.FirstOrDefault(s => s.UserID == userId) ?? scores.FirstOrDefault(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs index d66b4f844c..eccbaf7930 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs @@ -1,19 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; -using osu.Game.Screens.OnlinePlay.Match; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -59,6 +60,29 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return criteria; } + protected override void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure) + { + var joinRoomRequest = new JoinRoomRequest(room, password); + + joinRoomRequest.Success += r => onSuccess(r); + joinRoomRequest.Failure += exception => + { + if (exception is not OperationCanceledException) + onFailure(exception.Message); + }; + + api.Queue(joinRoomRequest); + } + + public override void Close(Room room) + { + Debug.Assert(room.RoomID != null); + + var request = new ClosePlaylistRequest(room.RoomID.Value); + request.Success += RefreshRooms; + api.Queue(request); + } + protected override OsuButton CreateNewRoomButton() => new CreatePlaylistsRoomButton(); protected override Room CreateNewRoom() @@ -70,9 +94,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }; } - protected override RoomSubScreen CreateRoomSubScreen(Room room) => new PlaylistsRoomSubScreen(room); - - protected override ListingPollingComponent CreatePollingComponent() => new ListingPollingComponent(); + protected override OnlinePlaySubScreen CreateRoomSubScreen(Room room) => new PlaylistsRoomSubScreen(room); private enum PlaylistsCategory { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index b82c2404ab..dc4078cb1f 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistItemScoreResultsScreen(score, Room.RoomID.Value, PlaylistItem) { AllowRetry = true, - ShowUserStatistics = true, + IsLocalPlay = true, }; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs index a460779ea6..e72f8be50a 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; @@ -10,7 +11,9 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -19,6 +22,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private IBindable gameBeatmap { get; set; } = null!; + [Resolved] + private IBindable> mods { get; set; } = null!; + private readonly Room room; public PlaylistsReadyButton(Room room) @@ -63,14 +69,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { base.Update(); - Enabled.Value = hasRemainingAttempts && enoughTimeLeft; + Enabled.Value = hasRemainingAttempts && enoughTimeLeft(); } public override LocalisableString TooltipText { get { - if (!enoughTimeLeft) + if (!enoughTimeLeft()) return "No time left!"; if (!hasRemainingAttempts) @@ -80,9 +86,17 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } - private bool enoughTimeLeft => - // This should probably consider the length of the currently selected item, rather than a constant 30 seconds. - room.EndDate != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(gameBeatmap.Value.Track.Length) < room.EndDate; + private bool enoughTimeLeft() + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + // We want to avoid users not being able to submit scores if they chose to not skip, + // so track length is chosen over playable length. + double trackLength = Math.Round(gameBeatmap.Value.Track.Length / rate); + + // Additional 30 second delay added to account for load and/or submit time. + return room.EndDate != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(trackLength) < room.EndDate; + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs index 0d837423a6..f9b1edcd59 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osuTK; @@ -12,22 +16,104 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public partial class PlaylistsRoomFooter : CompositeDrawable { public Action? OnStart; + public Action? OnClose; + + private readonly Room room; + private DangerousRoundedButton closeButton = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; public PlaylistsRoomFooter(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() { RelativeSizeAxes = Axes.Both; - InternalChildren = new[] + InternalChild = new FillFlowContainer { - new PlaylistsReadyButton(room) + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(600, 1), - Action = () => OnStart?.Invoke() + new PlaylistsReadyButton(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(600, 1), + Action = () => OnStart?.Invoke() + }, + closeButton = new DangerousRoundedButton + { + Text = "Close", + Action = () => OnClose?.Invoke(), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(120, 1), + Alpha = 0, + RelativeSizeAxes = Axes.Y, + } } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomChanged; + updateState(); + } + + private void hideCloseButton() + { + closeButton.ResizeWidthTo(0, 100, Easing.OutQuint) + .Then().FadeOut().Expire(); + } + + private void onRoomChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Status): + case nameof(Room.Host): + case nameof(Room.StartDate): + updateState(); + break; + } + } + + private void updateState() + { + TimeSpan? deletionGracePeriodRemaining = room.StartDate?.AddMinutes(5) - DateTimeOffset.Now; + + if (room.Host?.Id == api.LocalUser.Value.Id) + { + if (deletionGracePeriodRemaining > TimeSpan.Zero && !room.HasEnded) + { + closeButton.FadeIn(); + using (BeginDelayedSequence(deletionGracePeriodRemaining.Value.TotalMilliseconds)) + hideCloseButton(); + } + else if (closeButton.Alpha > 0) + hideCloseButton(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + room.PropertyChanged -= onRoomChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs new file mode 100644 index 0000000000..9c85088cc9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class PlaylistsRoomFreestyleSelect : OnlinePlayFreestyleSelect + { + public new readonly Bindable Beatmap = new Bindable(); + public new readonly Bindable Ruleset = new Bindable(); + + public PlaylistsRoomFreestyleSelect(Room room, PlaylistItem item) + : base(room, item) + { + } + + protected override bool OnStart() + { + // Beatmaps without a valid online ID are filtered away; this is just a final safety. + if (base.Beatmap.Value.BeatmapInfo.OnlineID < 0) + return false; + + if (base.Ruleset.Value.OnlineID < 0) + return false; + + Beatmap.Value = base.Beatmap.Value.BeatmapInfo; + Ruleset.Value = base.Ruleset.Value; + this.Exit(); + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 88af161cc8..9c0363f40e 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -75,9 +75,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private PurpleRoundedButton editPlaylistButton = null!; - [Resolved] - private IRoomManager? manager { get; set; } - [Resolved] private IAPIProvider api { get; set; } = null!; @@ -440,7 +437,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (!ApplyButton.Enabled.Value) return; - hideError(); + ErrorText.FadeOut(50); room.Name = NameField.Text; room.Availability = AvailabilityPicker.Current.Value; @@ -449,13 +446,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists room.Duration = DurationField.Current.Value; loadingLayer.Show(); - manager?.CreateRoom(room, onSuccess, onError); + + var req = new CreateRoomRequest(room); + req.Success += _ => loadingLayer.Hide(); + req.Failure += e => onError(req.Response?.Error ?? e.Message); + api.Queue(req); } - private void hideError() => ErrorText.FadeOut(50); - - private void onSuccess(Room room) => loadingLayer.Hide(); - private void onError(string text) { // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 1aaae60195..ae31e55da5 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -1,54 +1,151 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; +using osu.Game.Utils; using osuTK; using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { - public partial class PlaylistsRoomSubScreen : RoomSubScreen + public partial class PlaylistsRoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { + /// + /// Footer height. + /// + private const float footer_height = 50; + + /// + /// Padding between content and footer. + /// + private const float footer_padding = 30; + + /// + /// Internal padding of the content. + /// + private const float content_padding = 20; + + /// + /// Padding between columns of the content. + /// + private const float column_padding = 10; + + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + public override string Title { get; } public override string ShortTitle => "playlist"; - private readonly IBindable isIdle = new BindableBool(); + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether the user has confirmed they want to exit this screen in the presence of unsaved changes. + /// + protected bool ExitConfirmed { get; private set; } [Resolved] private IAPIProvider api { get; set; } = null!; - [Resolved(CanBeNull = true)] + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] private IdleTracker? idleTracker { get; set; } + [Resolved] + private OnlinePlayScreen? parentScreen { get; set; } + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Cached] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + + protected readonly Bindable SelectedItem = new Bindable(); + protected readonly Bindable UserBeatmap = new Bindable(); + protected readonly Bindable UserRuleset = new Bindable(); + protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); + + private readonly IBindable isIdle = new BindableBool(); + private readonly Room room; + + private Drawable roomContent = null!; + private PlaylistsRoomUpdater roomUpdater = null!; + private PlaylistsRoomSettingsOverlay settingsOverlay = null!; + private MatchLeaderboard leaderboard = null!; - private SelectionPollingComponent selectionPollingComponent = null!; private FillFlowContainer progressSection = null!; private DrawableRoomPlaylist drawablePlaylist = null!; + private FillFlowContainer userModsSection = null!; + private RoomModSelectOverlay userModsSelectOverlay = null!; + + private FillFlowContainer userStyleSection = null!; + private Container userStyleDisplayContainer = null!; + + private Sample? sampleStart; + private IDisposable? userModsSelectOverlayRegistration; + public PlaylistsRoomSubScreen(Room room) - : base(room, false) // Editing is temporarily not allowed. { + this.room = room; + Title = room.RoomID == null ? "New playlist" : room.Name; Activity.Value = new UserActivity.InLobby(room); + + Padding = new MarginPadding { Top = Header.HEIGHT }; } [BackgroundDependencyLoader] @@ -57,21 +154,328 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); - AddInternal(selectionPollingComponent = new SelectionPollingComponent(Room)); + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + roomUpdater = new PlaylistsRoomUpdater(room), + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = footer_height + footer_padding + }, + Children = new[] + { + roomContent = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, row_padding), + }, + Content = new[] + { + new Drawable[] + { + new DrawableMatchRoom(room, false) + { + OnEdit = () => settingsOverlay.Show(), + SelectedItem = SelectedItem + } + }, + null, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(content_padding), + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + }, + Content = new[] + { + new Drawable?[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OverlinedPlaylistHeader(room), + }, + new Drawable[] + { + drawablePlaylist = new DrawableRoomPlaylist + { + RelativeSizeAxes = Axes.Both, + SelectedItem = { BindTarget = SelectedItem }, + AllowSelection = true, + AllowShowingResults = true, + RequestResults = item => + { + Debug.Assert(room.RoomID != null); + parentScreen?.Push(new PlaylistItemUserBestResultsScreen(room.RoomID.Value, item, + api.LocalUser.Value.Id)); + } + } + }, + new Drawable[] + { + new AddPlaylistToCollectionButton(room) + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + Size = new Vector2(1, 40) + } + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + userModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Height = 30, + Text = "Select", + Action = showUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + } + } + } + } + } + }, + new Drawable[] + { + userStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + userStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + new Drawable[] + { + progressSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + Alpha = 0, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OverlinedHeader("Progress"), + new RoomLocalUserInfo(room), + } + } + }, + new Drawable[] + { + new OverlinedHeader("Leaderboard") + }, + new Drawable[] + { + leaderboard = new MatchLeaderboard(room) + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Chat") + }, + new Drawable[] + { + new MatchChatDisplay(room) + { + RelativeSizeAxes = Axes.Both + } + } + } + } + } + } + } + } + } + } + } + }, + settingsOverlay = new PlaylistsRoomSettingsOverlay(room) + { + EditPlaylist = () => + { + if (this.IsCurrentScreen()) + this.Push(new PlaylistsSongSelect(room)); + } + } + } + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = footer_height, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = new PlaylistsRoomFooter(room) + { + OnStart = startPlay, + OnClose = closePlaylist + } + } + } + } + } + }; + + LoadComponent(userModsSelectOverlay = new RoomModSelectOverlay + { + SelectedItem = { BindTarget = SelectedItem }, + SelectedMods = { BindTarget = UserMods }, + IsValidMod = _ => false + }); } protected override void LoadComplete() { base.LoadComplete(); + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); + + room.PropertyChanged += onRoomPropertyChanged; + isIdle.BindValueChanged(_ => updatePollingRate(), true); - Room.PropertyChanged += onRoomPropertyChanged; + SelectedItem.BindValueChanged(onSelectedItemChanged); + + beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateGameplayState()); + + UserBeatmap.BindValueChanged(_ => updateGameplayState()); + UserMods.BindValueChanged(_ => updateGameplayState()); + UserRuleset.BindValueChanged(_ => + { + // The user mod selection overlay is separate from the beatmap/ruleset style selection screen, + // and so the validity of mods has to be confirmed separately after the ruleset is changed. + validateUserMods(); + updateGameplayState(); + }); + updateSetupState(); - updateRoomMaxAttempts(); - updateRoomPlaylist(); + updateGameplayState(); } + /// + /// Responds to changes of the 's properties. + /// + /// The that changed. + /// Describes the property that changed. private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) @@ -79,216 +483,377 @@ namespace osu.Game.Screens.OnlinePlay.Playlists case nameof(Room.RoomID): updateSetupState(); break; - - case nameof(Room.MaxAttempts): - updateRoomMaxAttempts(); - break; - - case nameof(Room.Playlist): - updateRoomPlaylist(); - break; } } + /// + /// Responds to changes in to adjust the visibility of the settings and main content. + /// Only the settings overlay is visible while the room isn't created, and only the main content is visible after creation. + /// private void updateSetupState() { - if (Room.RoomID != null) + if (room.RoomID == null) { - // Set the first playlist item. - // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). - Schedule(() => SelectedItem.Value = Room.Playlist.FirstOrDefault()); + // A new room is being created. + // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. + roomContent.Hide(); + settingsOverlay.Show(); + } + else + { + roomContent.Show(); + settingsOverlay.Hide(); + + // Scheduled because room properties are updated in arbitrary order. + Schedule(() => + { + progressSection.Alpha = room.MaxAttempts != null ? 1 : 0; + drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, room.Playlist); + + // Select an initial item for the user to help them get into a playable state quicker. + SelectedItem.Value = room.Playlist.FirstOrDefault(); + }); } } - private void updateRoomMaxAttempts() - => progressSection.Alpha = Room.MaxAttempts != null ? 1 : 0; - - private void updateRoomPlaylist() - => drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, Room.Playlist); - - protected override Drawable CreateMainContent() => new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable?[] - { - // Playlist items column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Content = new[] - { - new Drawable[] { new OverlinedPlaylistHeader(Room), }, - new Drawable[] - { - drawablePlaylist = new DrawableRoomPlaylist - { - RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = SelectedItem }, - AllowSelection = true, - AllowShowingResults = true, - RequestResults = item => - { - Debug.Assert(Room.RoomID != null); - ParentScreen?.Push(new PlaylistItemUserBestResultsScreen(Room.RoomID.Value, item, api.LocalUser.Value.Id)); - } - } - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Middle column (mods and leaderboard) - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } - } - }, - }, - new Drawable[] - { - progressSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OverlinedHeader("Progress"), - new RoomLocalUserInfo(Room), - } - }, - }, - new Drawable[] - { - new OverlinedHeader("Leaderboard") - }, - new Drawable[] { leaderboard = new MatchLeaderboard(Room) { RelativeSizeAxes = Axes.Both }, }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - }, - }, - } - } - }; - - protected override Drawable CreateFooter() => new PlaylistsRoomFooter(Room) - { - OnStart = StartPlay - }; - - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new PlaylistsRoomSettingsOverlay(room) - { - EditPlaylist = () => - { - if (this.IsCurrentScreen()) - this.Push(new PlaylistsSongSelect(Room)); - }, - }; - + /// + /// Adjusts the rate at which the is updated. + /// private void updatePollingRate() { - selectionPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; - Logger.Log($"Polling adjusted (selection: {selectionPollingComponent.TimeBetweenPolls.Value})"); + roomUpdater.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; + Logger.Log($"Polling adjusted (selection: {roomUpdater.TimeBetweenPolls.Value})"); } - protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) + /// + /// Responds to changes in to validate the user style and update the global gameplay state. + /// + private void onSelectedItemChanged(ValueChangedEvent item) { - return new PlayerLoader(() => new PlaylistsPlayer(Room, selectedItem) + if (item.NewValue == null) + return; + + // Always resetting the user beatmap style when a new item is selected is most intuitive. + UserBeatmap.Value = null; + + if (item.NewValue.Freestyle) + { + // If freestyle is active, attempt to preserve the user ruleset style but only if the online item is from the osu! ruleset + // (i.e. the beatmap is generally always convertible to the current ruleset, excluding custom rulesets). + if (item.NewValue.RulesetID > 0) + UserRuleset.Value = null; + } + else + UserRuleset.Value = null; + + validateUserMods(); + updateGameplayState(); + } + + /// + /// Lists the s that are valid to be selected for the user mod style. + /// + private Mod[] listAllowedMods() + { + if (SelectedItem.Value == null) + return []; + + PlaylistItem item = SelectedItem.Value; + + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); + + if (item.Freestyle) + return rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray(); + + return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + } + + /// + /// Validates the user mod style against the selected item and ruleset style. + /// + private void validateUserMods() + { + Mod[] allowedMods = listAllowedMods(); + UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); + } + + /// + /// Updates the global states in preparation for a new gameplay session. + /// + private void updateGameplayState() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + + IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); + Mod[] allowedMods = listAllowedMods(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + int beatmapId = gameplayBeatmap.OnlineID; + var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == beatmapId); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = gameplayRuleset; + Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray(); + + // Update UI elements to reflect the new selection. + bool freemods = allowedMods.Length > 0; + bool freestyle = item.Freestyle; + + if (freemods) + { + userModsSection.Show(); + userModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + } + else + { + userModsSection.Hide(); + userModsSelectOverlay.Hide(); + userModsSelectOverlay.IsValidMod = _ => false; + } + + if (freestyle) + { + userStyleSection.Show(); + + PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional(gameplayBeatmap)); + PlaylistItem? currentItem = userStyleDisplayContainer.SingleOrDefault()?.Item; + + if (!gameplayItem.Equals(currentItem)) + { + userStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true) + { + AllowReordering = false, + AllowEditing = true, + RequestEdit = _ => showUserStyleSelect() + }; + } + } + else + userStyleSection.Hide(); + } + + /// + /// Pushes a to start gameplay with the current selection. + /// + private void startPlay() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + + // Required for validation inside the player. + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; + PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional(gameplayBeatmap)); + + sampleStart?.Play(); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + targetScreen.Push(new PlayerLoader(() => new PlaylistsPlayer(room, gameplayItem) { Exited = () => leaderboard.RefetchScores() + })); + } + + /// + /// Shows the user mod selection. + /// + private void showUserModSelect() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + userModsSelectOverlay.Show(); + } + + /// + /// Shows the user style selection. + /// + private void showUserStyleSelect() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + this.Push(new PlaylistsRoomFreestyleSelect(room, SelectedItem.Value) + { + Beatmap = { BindTarget = UserBeatmap }, + Ruleset = { BindTarget = UserRuleset } }); } + /// + /// May be invoked by the owner of the room to permanently close the room ahead of its intended end date. + /// + private void closePlaylist() + { + dialogOverlay?.Push(new ClosePlaylistDialog(room, () => + { + var request = new ClosePlaylistRequest(room.RoomID!.Value); + request.Success += () => room.EndDate = DateTimeOffset.UtcNow; + api.Queue(request); + })); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + onLeaving(); + base.OnSuspending(e); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + beginHandlingTrack(); + + // Required to update beatmap/ruleset when resuming from style selection. + updateGameplayState(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (!ensureExitConfirmed()) + return true; + + if (room.RoomID != null) + api.Queue(new PartRoomRequest(room)); + + onLeaving(); + return base.OnExiting(e); + } + + public override bool OnBackButton() + { + if (room.RoomID == null) + { + if (!ensureExitConfirmed()) + return true; + + settingsOverlay.Hide(); + return base.OnBackButton(); + } + + if (userModsSelectOverlay.State.Value == Visibility.Visible) + { + userModsSelectOverlay.Hide(); + return true; + } + + if (settingsOverlay.State.Value == Visibility.Visible) + { + settingsOverlay.Hide(); + return true; + } + + return base.OnBackButton(); + } + + private void onLeaving() + { + // Must hide this overlay because it is added to a global container. + userModsSelectOverlay.Hide(); + + endHandlingTrack(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + /// + /// Prompts the user to discard unsaved changes to the room before exiting. + /// + /// true if the user has confirmed they want to exit. + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (api.State.Value != APIState.Online) + return true; + + bool hasUnsavedChanges = room.RoomID == null && room.Playlist.Count > 0; + + if (dialogOverlay == null || !hasUnsavedChanges) + return true; + + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + { + discardChangesDialog.Flash(); + return false; + } + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagateNonPositionalInputSubTree => base.PropagateNonPositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + protected override BackgroundScreen CreateBackground() => new RoomBackgroundScreen(room.Playlist.FirstOrDefault()) + { + SelectedItem = { BindTarget = SelectedItem } + }; + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - Room.PropertyChanged -= onRoomPropertyChanged; + + userModsSelectOverlayRegistration?.Dispose(); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs similarity index 65% rename from osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs rename to osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs index 7cee8b3546..f68703750a 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs @@ -2,18 +2,24 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.Rooms; -namespace osu.Game.Screens.OnlinePlay.Components +namespace osu.Game.Screens.OnlinePlay.Playlists { /// - /// A that polls for the currently-selected room. + /// A that polls for and updates a room. /// - public partial class SelectionPollingComponent : RoomPollingComponent + public partial class PlaylistsRoomUpdater : PollingComponent { + [Resolved] + private IAPIProvider api { get; set; } = null!; + private readonly Room room; - public SelectionPollingComponent(Room room) + public PlaylistsRoomUpdater(Room room) { this.room = room; } @@ -22,27 +28,26 @@ namespace osu.Game.Screens.OnlinePlay.Components protected override Task Poll() { - if (!API.IsLoggedIn) + if (!api.IsLoggedIn) return base.Poll(); if (room.RoomID == null) return base.Poll(); - var tcs = new TaskCompletionSource(); - lastPollRequest?.Cancel(); + var tcs = new TaskCompletionSource(); var req = new GetRoomRequest(room.RoomID.Value); req.Success += result => { - RoomManager.AddOrUpdateRoom(result); + room.CopyFrom(result); tcs.SetResult(true); }; req.Failure += _ => tcs.SetResult(false); - API.Queue(req); + api.Queue(req); lastPollRequest = req; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 23824b6a73..84446ed0cf 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -39,7 +39,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = Freestyle.Value }; } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 695a074907..ce04db0189 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -37,7 +37,7 @@ namespace osu.Game.Screens public string Description => Title; - public virtual bool AllowBackButton => true; + public virtual bool AllowUserExit => true; public virtual bool ShowFooter => false; @@ -47,6 +47,8 @@ namespace osu.Game.Screens public virtual bool HideMenuCursorOnNonMouseInput => false; + public virtual bool RequiresPortraitOrientation => false; + /// /// The initial overlay activation mode to use when this screen is entered for the first time. /// @@ -56,6 +58,15 @@ namespace osu.Game.Screens IBindable IOsuScreen.OverlayActivationMode => OverlayActivationMode; + /// + /// The initial visibility state of the back button when this screen is entered for the first time. + /// + protected virtual bool InitialBackButtonVisibility => AllowUserExit; + + public readonly Bindable BackButtonVisibility; + + IBindable IOsuScreen.BackButtonVisibility => BackButtonVisibility; + public virtual bool CursorVisible => true; protected new OsuGameBase Game => base.Game as OsuGameBase; @@ -72,7 +83,7 @@ namespace osu.Game.Screens /// protected readonly Bindable Activity = new Bindable(); - IBindable IOsuScreen.Activity => Activity; + Bindable IOsuScreen.Activity => Activity; /// /// Whether to disallow changes to game-wise Beatmap/Ruleset bindables for this screen (and all children). @@ -154,6 +165,7 @@ namespace osu.Game.Screens Origin = Anchor.Centre; OverlayActivationMode = new Bindable(InitialOverlayActivationMode); + BackButtonVisibility = new Bindable(InitialBackButtonVisibility); } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs deleted file mode 100644 index 9308a02b07..0000000000 --- a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osuTK.Graphics; - -namespace osu.Game.Screens.Play.Break -{ - public partial class LetterboxOverlay : CompositeDrawable - { - private static readonly Color4 transparent_black = new Color4(0, 0, 0, 0); - - public LetterboxOverlay() - { - const int height = 150; - - RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] - { - new Box - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - RelativeSizeAxes = Axes.X, - Height = height, - Colour = ColourInfo.GradientVertical(Color4.Black, transparent_black), - }, - new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = height, - Colour = ColourInfo.GradientVertical(transparent_black, Color4.Black), - } - }; - } - } -} diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 550d29965f..2ae66a6dc4 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -35,7 +35,7 @@ namespace osu.Game.Screens.Play public override bool RemoveCompletedTransforms => false; - public BreakTracker BreakTracker { get; init; } = null!; + public required BreakTracker BreakTracker { get; init; } private readonly Container remainingTimeAdjustmentBox; private readonly Container remainingTimeBox; @@ -46,7 +46,7 @@ namespace osu.Game.Screens.Play private readonly IBindable currentPeriod = new Bindable(); - public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor) + public BreakOverlay(ScoreProcessor scoreProcessor) { this.scoreProcessor = scoreProcessor; RelativeSizeAxes = Axes.Both; @@ -63,12 +63,6 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new LetterboxOverlay - { - Alpha = letterboxing ? 1 : 0, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, new CircularContainer { Anchor = Anchor.Centre, @@ -165,7 +159,7 @@ namespace osu.Game.Screens.Play if (currentPeriod.Value == null) return; - float timeBoxTargetWidth = (float)Math.Max(0, (remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration)); + float timeBoxTargetWidth = (float)Math.Max(0, remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration); remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); } diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 84d99ea863..a096400fe0 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -69,7 +69,22 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { - ShowStoryboard.BindValueChanged(_ => initializeStoryboard(true), true); + ShowStoryboard.BindValueChanged(show => + { + initializeStoryboard(true); + + if (drawableStoryboard != null) + { + // Regardless of user dim setting, for the time being we need to ensure storyboards are still updated in the background (even if not displayed). + // If we don't do this, an intensive storyboard will have a lot of catch-up work to do at the start of a break, causing a huge stutter. + // + // This can be reconsidered when https://github.com/ppy/osu-framework/issues/6491 is resolved. + bool alwaysPresent = show.NewValue; + + Content.AlwaysPresent = alwaysPresent; + drawableStoryboard.AlwaysPresent = alwaysPresent; + } + }, true); base.LoadComplete(); } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 255877e0aa..2afdcfaebb 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -39,6 +39,8 @@ namespace osu.Game.Screens.Play /// public double StartTime { get; protected set; } + public double GameplayStartTime { get; protected set; } + public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments(); private readonly BindableBool isPaused = new BindableBool(true); diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 2b961278d5..ffd7845356 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -166,11 +166,6 @@ namespace osu.Game.Screens.Play protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); - // Don't let mouse down events through the overlay or people can click circles while paused. - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnMouseMove(MouseMoveEvent e) => true; - protected void AddButton(LocalisableString text, Color4 colour, Action? action) { var button = new Button diff --git a/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs b/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs new file mode 100644 index 0000000000..059d5a0dd4 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Overlays.Volume; + +namespace osu.Game.Screens.Play +{ + /// + /// Primarily handles volume adjustment in gameplay. + /// + /// - If the user has mouse wheel disabled, only allow during break time or when holding alt. Also block scroll from parent handling. + /// - Otherwise always allow, as per implementation. + /// + internal partial class GameplayScrollWheelHandling : GlobalScrollAdjustsVolume + { + private Bindable mouseWheelDisabled = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); + } + + protected override bool OnScroll(ScrollEvent e) + { + // During pause, allow global volume adjust regardless of settings. + if (gameplayClock.IsPaused.Value) + return base.OnScroll(e); + + // Block any parent handling of scroll if the user has asked for it (special case when holding "Alt"). + if (mouseWheelDisabled.Value && !e.AltPressed) + return true; + + return base.OnScroll(e); + } + } +} diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 478acd7229..851e95495f 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -69,6 +69,11 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); + /// + /// The local user's playing state (whether actively playing, paused, or not playing due to watching a replay or similar). + /// + public IBindable PlayingState { get; } = new Bindable(); + public GameplayState( IBeatmap beatmap, Ruleset ruleset, @@ -76,7 +81,8 @@ namespace osu.Game.Screens.Play Score? score = null, ScoreProcessor? scoreProcessor = null, HealthProcessor? healthProcessor = null, - Storyboard? storyboard = null) + Storyboard? storyboard = null, + IBindable? localUserPlayingState = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -92,6 +98,9 @@ namespace osu.Game.Screens.Play ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); HealthProcessor = healthProcessor ?? ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); Storyboard = storyboard ?? new Storyboard(); + + if (localUserPlayingState != null) + PlayingState.BindTo(localUserPlayingState); } /// diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index d2b6b834f8..f6694505dc 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -114,15 +114,15 @@ namespace osu.Game.Screens.Play.HUD if (requiresScroll && TrackedScore != null) { - float scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + double scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; scroll.ScrollTo(scrollTarget); } const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; - float fadeBottom = scroll.Current + scroll.DrawHeight; - float fadeTop = scroll.Current + panel_height; + float fadeBottom = (float)(scroll.Current + scroll.DrawHeight); + float fadeTop = (float)(scroll.Current + panel_height); if (scroll.IsScrolledToStart()) fadeTop -= panel_height; if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index b37d41e7a2..986bc525cc 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -8,7 +8,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osuTK; @@ -20,9 +22,24 @@ namespace osu.Game.Screens.Play.HUD /// public partial class ModDisplay : CompositeDrawable, IHasCurrentValue> { - private const int fade_duration = 1000; + public const float MOD_ICON_SCALE = 0.6f; - public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; + private ExpansionMode expansionMode = ExpansionMode.ExpandOnHover; + + public ExpansionMode ExpansionMode + { + get => expansionMode; + set + { + if (expansionMode == value) + return; + + expansionMode = value; + + if (IsLoaded) + updateExpansionMode(); + } + } private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); @@ -37,7 +54,25 @@ namespace osu.Game.Screens.Play.HUD } } - private readonly bool showExtendedInformation; + private bool showExtendedInformation; + + public bool ShowExtendedInformation + { + get => showExtendedInformation; + set + { + showExtendedInformation = value; + foreach (var icon in iconsContainer) + icon.ShowExtendedInformation = value; + } + } + + public FillDirection FillDirection + { + get => iconsContainer.Direction; + set => iconsContainer.Direction = value; + } + private readonly FillFlowContainer iconsContainer; public ModDisplay(bool showExtendedInformation = true) @@ -58,11 +93,7 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); Current.BindValueChanged(updateDisplay, true); - - iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); - - if (ExpansionMode == ExpansionMode.AlwaysExpanded || ExpansionMode == ExpansionMode.AlwaysContracted) - FinishTransforms(true); + updateExpansionMode(0); } private void updateDisplay(ValueChangedEvent> mods) @@ -70,29 +101,40 @@ namespace osu.Game.Screens.Play.HUD iconsContainer.Clear(); foreach (Mod mod in mods.NewValue.AsOrdered()) - iconsContainer.Add(new ModIcon(mod, showExtendedInformation: showExtendedInformation) { Scale = new Vector2(0.6f) }); - - appearTransform(); + iconsContainer.Add(new ModIcon(mod, showExtendedInformation: showExtendedInformation) { Scale = new Vector2(MOD_ICON_SCALE) }); } - private void appearTransform() + private void updateExpansionMode(double duration = 500) { - expand(); + switch (expansionMode) + { + case ExpansionMode.AlwaysExpanded: + expand(duration); + break; - using (iconsContainer.BeginDelayedSequence(1200)) - contract(); + case ExpansionMode.AlwaysContracted: + contract(duration); + break; + + case ExpansionMode.ExpandOnHover: + if (IsHovered) + expand(duration); + else + contract(duration); + break; + } } - private void expand() + private void expand(double duration = 500) { if (ExpansionMode != ExpansionMode.AlwaysContracted) - iconsContainer.TransformSpacingTo(new Vector2(5, 0), 500, Easing.OutQuint); + iconsContainer.TransformSpacingTo(new Vector2(5, -10), duration, Easing.OutQuint); } - private void contract() + private void contract(double duration = 500) { if (ExpansionMode != ExpansionMode.AlwaysExpanded) - iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + iconsContainer.TransformSpacingTo(new Vector2(-25), duration, Easing.OutQuint); } protected override bool OnHover(HoverEvent e) @@ -113,16 +155,19 @@ namespace osu.Game.Screens.Play.HUD /// /// The will expand only when hovered. /// + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ExpandOnHover))] ExpandOnHover, /// /// The will always be expanded. /// + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.AlwaysExpanded))] AlwaysExpanded, /// /// The will always be contracted. /// - AlwaysContracted + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.AlwaysContracted))] + AlwaysContracted, } } diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index a2b49f6302..b285b1b799 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -1,46 +1,137 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osuTK; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play.PlayerSettings; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public partial class PlayerSettingsOverlay : VisibilityContainer + public partial class PlayerSettingsOverlay : ExpandingContainer { + public VisualSettings VisualSettings { get; private set; } + + private const float padding = 10; + + public const float EXPANDED_WIDTH = player_settings_width + padding * 2; + + private const float player_settings_width = 270; + private const int fade_duration = 200; - public readonly VisualSettings VisualSettings; + public override void Show() => this.FadeIn(fade_duration); + public override void Hide() => this.FadeOut(fade_duration); + + // we'll handle this ourselves because we have slightly custom logic. + protected override bool ExpandOnHover => false; protected override Container Content => content; private readonly FillFlowContainer content; - public PlayerSettingsOverlay() - { - Anchor = Anchor.TopRight; - Origin = Anchor.TopRight; - AutoSizeAxes = Axes.Both; + private readonly IconButton button; - InternalChild = content = new FillFlowContainer + private InputManager inputManager = null!; + + [Resolved] + private HUDOverlay? hudOverlay { get; set; } + + public PlayerSettingsOverlay() + : base(0, EXPANDED_WIDTH) + { + Origin = Anchor.TopRight; + Anchor = Anchor.TopRight; + + base.Content.Add(content = new FillFlowContainer { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), + Margin = new MarginPadding(padding), Children = new PlayerSettingsGroup[] { VisualSettings = new VisualSettings { Expanded = { Value = false } }, new AudioSettings { Expanded = { Value = false } } } - }; + }); + + // For future consideration, this icon should probably not exist. + // + // If we remove it, the following needs attention: + // - Mobile support (swipe from side of screen?) + // - Consolidating this overlay with the one at player loader (to have the animation hint at its presence) + AddInternal(button = new IconButton + { + Icon = FontAwesome.Solid.Cog, + Origin = Anchor.TopRight, + Anchor = Anchor.TopLeft, + Margin = new MarginPadding(5), + Action = () => Expanded.Toggle() + }); + + AddInternal(new Box + { + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0), Color4.Black.Opacity(0.8f)), + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + }); } - protected override void PopIn() => this.FadeIn(fade_duration); - protected override void PopOut() => this.FadeOut(fade_duration); + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager()!; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + screenSpacePos.X > button.ScreenSpaceDrawQuad.TopLeft.X; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + checkExpanded(); + return base.OnMouseMove(e); + } + + protected override void Update() + { + base.Update(); + + if (hudOverlay != null) + button.Y = ToLocalSpace(hudOverlay.TopRightElements.ScreenSpaceDrawQuad.BottomRight).Y; + + // Only check expanded if already expanded. + // This is because if we are always checking, it would bypass blocking overlays. + // Case in point: the skin editor overlay blocks input from reaching the player, but checking raw coordinates would make settings pop out. + if (Expanded.Value) + checkExpanded(); + } + + private void checkExpanded() + { + float screenMouseX = inputManager.CurrentState.Mouse.Position.X; + + Expanded.Value = + (screenMouseX >= button.ScreenSpaceDrawQuad.TopLeft.X && screenMouseX <= ToScreenSpace(new Vector2(DrawWidth + EXPANDED_WIDTH, 0)).X) + // Stay expanded if the user is dragging a slider. + || inputManager.DraggedDrawable != null; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + // handle un-expanding manually because our children do weird hover blocking stuff. + } public void AddAtStart(PlayerSettingsGroup drawable) => content.Insert(-1, drawable); } diff --git a/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs new file mode 100644 index 0000000000..3f72099a45 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class PlayerTeamFlag : CompositeDrawable, ISerialisableDrawable + { + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => false; + + private readonly UpdateableTeamFlag flag; + + private const float default_size = 40f; + + [Resolved] + private GameplayState? gameplayState { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable? apiUser; + + public PlayerTeamFlag() + { + Size = new Vector2(default_size, default_size / 2f); + + InternalChild = flag = new UpdateableTeamFlag + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(UserLookupCache userLookupCache) + { + if (gameplayState != null) + { + if (gameplayState.Score.ScoreInfo.User.Team != null) + flag.Team = gameplayState.Score.ScoreInfo.User.Team; + else + { + // We only store very basic information about a user to realm, so there's a high chance we don't have the team information. + userLookupCache.GetUserAsync(gameplayState.Score.ScoreInfo.User.Id) + .ContinueWith(task => Schedule(() => flag.Team = task.GetResultSafely()?.Team)); + } + } + else + { + apiUser = api.LocalUser.GetBoundCopy(); + apiUser.BindValueChanged(u => flag.Team = u.NewValue.Team, true); + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs b/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs new file mode 100644 index 0000000000..29b8429539 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Displays a single-line horizontal auto-sized flow of mods. For cases where wrapping is required, use instead. + /// + public partial class SkinnableModDisplay : CompositeDrawable, ISerialisableDrawable + { + private ModDisplay modDisplay = null!; + + [Resolved] + private Bindable> mods { get; set; } = null!; + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ShowExtendedInformation), nameof(SkinnableModDisplayStrings.ShowExtendedInformationDescription))] + public Bindable ShowExtendedInformation { get; } = new Bindable(true); + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ExpansionMode), nameof(SkinnableModDisplayStrings.ExpansionModeDescription))] + public Bindable ExpansionModeSetting { get; } = new Bindable(); + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.DisplayDirection))] + public Bindable Direction { get; } = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + // Provide a minimum autosize. + new Container { Size = ModIcon.MOD_ICON_SIZE * ModDisplay.MOD_ICON_SCALE }, + modDisplay = new ModDisplay(), + }; + + modDisplay.Current = mods; + AutoSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ShowExtendedInformation.BindValueChanged(_ => modDisplay.ShowExtendedInformation = ShowExtendedInformation.Value, true); + ExpansionModeSetting.BindValueChanged(_ => modDisplay.ExpansionMode = ExpansionModeSetting.Value, true); + Direction.BindValueChanged(_ => modDisplay.FillDirection = Direction.Value == Framework.Graphics.Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical, true); + + FinishTransforms(true); + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs new file mode 100644 index 0000000000..0660c1c8db --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -0,0 +1,301 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.HUD; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class SpectatorList : CompositeDrawable, ISerialisableDrawable + { + private const int max_spectators_displayed = 10; + + public Bindable HeaderFont { get; } = new Bindable(Typeface.Torus); + public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White); + + private IBindableList watchingUsers { get; } = new BindableList(); + private IBindableList multiplayerPlayers { get; } = new BindableList(); + private BindableList actualSpectators { get; } = new BindableList(); + + private Bindable userPlayingState { get; } = new Bindable(); + + private OsuSpriteText header = null!; + private FillFlowContainer mainFlow = null!; + private FillFlowContainer spectatorsFlow = null!; + private DrawablePool pool = null!; + + [Resolved] + private SpectatorClient client { get; set; } = null!; + + [Resolved] + private GameplayState gameplayState { get; set; } = null!; + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + Empty().With(t => t.Size = new Vector2(100, 50)), + mainFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + header = new OsuSpriteText + { + Colour = colours.Blue0, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }, + spectatorsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + } + } + }, + pool = new DrawablePool(max_spectators_displayed), + }; + + HeaderColour.Value = header.Colour; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((IBindable)userPlayingState).BindTo(gameplayState.PlayingState); + + multiplayerPlayers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); + multiplayerPlayers.BindCollectionChanged((_, _) => removePlayersFromMultiplayerRoom()); + + watchingUsers.BindTo(client.WatchingUsers); + watchingUsers.BindCollectionChanged(onWatchingUsersChanged, true); + + actualSpectators.BindCollectionChanged(onSpectatorsChanged, true); + userPlayingState.BindValueChanged(_ => updateVisibility()); + + HeaderFont.BindValueChanged(_ => updateAppearance()); + HeaderColour.BindValueChanged(_ => updateAppearance(), true); + FinishTransforms(true); + + this.FadeInFromZero(200, Easing.OutQuint); + } + + private void onWatchingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + actualSpectators.Add((SpectatorUser)e.NewItems![i]!); + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + for (int i = 0; i < e.OldItems!.Count; i++) + actualSpectators.Remove((SpectatorUser)e.OldItems![i]!); + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + actualSpectators.Clear(); + break; + } + + default: + throw new NotSupportedException(); + } + + removePlayersFromMultiplayerRoom(); + } + + private void removePlayersFromMultiplayerRoom() + { + // the multiplayer gameplay leaderboard relies on calling `SpectatorClient.WatchUser()` to get updates on users' total scores. + // this has an unfortunate side effect of other players showing up in `SpectatorClient.WatchingUsers`. + // + // we do not generally wish to display other players in the room as spectators due to that implementation detail, + // therefore this code is intended to filter out those players on the client side. + actualSpectators.RemoveAll(s => multiplayerPlayers.Contains(s.OnlineID)); + } + + private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + { + var spectator = (SpectatorUser)e.NewItems![i]!; + int index = Math.Max(e.NewStartingIndex, 0) + i; + + if (index >= max_spectators_displayed) + break; + + addNewSpectatorToList(index, spectator); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + spectatorsFlow.RemoveAll(entry => e.OldItems!.Contains(entry.Current.Value), false); + + for (int i = 0; i < spectatorsFlow.Count; i++) + spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i); + + if (actualSpectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed) + { + for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++) + addNewSpectatorToList(i, actualSpectators[i]); + } + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + spectatorsFlow.Clear(false); + break; + } + + default: + throw new NotSupportedException(); + } + + header.Text = SpectatorListStrings.SpectatorCount(actualSpectators.Count).ToUpper(); + updateVisibility(); + + for (int i = 0; i < spectatorsFlow.Count; i++) + { + spectatorsFlow[i].Colour = i < max_spectators_displayed - 1 + ? Color4.White + : ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0)); + } + } + + private void addNewSpectatorToList(int i, SpectatorUser spectator) + { + var entry = pool.Get(entry => + { + entry.Current.Value = spectator; + entry.UserPlayingState = userPlayingState; + }); + + spectatorsFlow.Insert(i, entry); + } + + private void updateVisibility() + { + // We don't want to show spectators when we are watching a replay. + mainFlow.FadeTo(actualSpectators.Count > 0 && userPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint); + } + + private void updateAppearance() + { + header.Font = OsuFont.GetFont(HeaderFont.Value, 12, FontWeight.Bold); + header.Colour = HeaderColour.Value; + + Width = header.DrawWidth; + } + + private partial class SpectatorListEntry : PoolableDrawable + { + public Bindable Current { get; } = new Bindable(); + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable UserPlayingState + { + get => current.Current; + set => current.Current = value; + } + + private OsuSpriteText username = null!; + private DrawableLinkCompiler? linkCompiler; + + [Resolved] + private OsuGame? game { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + username = new OsuSpriteText(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + UserPlayingState.BindValueChanged(_ => updateEnabledState()); + Current.BindValueChanged(_ => updateState(), true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + username.MoveToX(10) + .Then() + .MoveToX(0, 400, Easing.OutQuint); + + this.FadeInFromZero(400, Easing.OutQuint); + } + + private void updateState() + { + username.Text = Current.Value.Username; + linkCompiler?.Expire(); + AddInternal(linkCompiler = new DrawableLinkCompiler([username]) + { + IdleColour = Colour4.White, + Action = () => game?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, Current.Value)), + }); + updateEnabledState(); + } + + private void updateEnabledState() + { + if (linkCompiler != null) + linkCompiler.Enabled.Value = UserPlayingState.Value != LocalUserPlayingState.Playing; + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index a6c2405eb6..75a28a4240 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -85,9 +85,9 @@ namespace osu.Game.Screens.Play private readonly BindableBool replayLoaded = new BindableBool(); private static bool hasShownNotificationOnce; - private readonly FillFlowContainer bottomRightElements; - private readonly FillFlowContainer topRightElements; + + internal readonly FillFlowContainer TopRightElements; internal readonly IBindable IsPlaying = new Bindable(); @@ -115,6 +115,8 @@ namespace osu.Game.Screens.Play public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { + Container rightSettings; + this.drawableRuleset = drawableRuleset; this.mods = mods; @@ -134,7 +136,7 @@ namespace osu.Game.Screens.Play PlayfieldSkinLayer = drawableRuleset != null ? new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.Playfield, drawableRuleset.Ruleset.RulesetInfo)) { AlwaysPresent = true, } : Empty(), - topRightElements = new FillFlowContainer + TopRightElements = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -145,8 +147,10 @@ namespace osu.Game.Screens.Play Direction = FillDirection.Vertical, Children = new Drawable[] { + // This display is potentially a duplicate of users with a local ModDisplay in their skins. + // It would be very nice to remove this, but the version here has special logic with regards to replays + // and initial states, so needs a bit of thought before doing so. ModDisplay = CreateModsContainer(), - PlayerSettingsOverlay = CreatePlayerSettingsOverlay(), } }, bottomRightElements = new FillFlowContainer @@ -164,6 +168,14 @@ namespace osu.Game.Screens.Play HoldToQuit = CreateHoldForMenuButton(), } }, + rightSettings = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + PlayerSettingsOverlay = new PlayerSettingsOverlay(), + } + }, LeaderboardFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -173,7 +185,7 @@ namespace osu.Game.Screens.Play }, }; - hideTargets = new List { mainComponents, topRightElements }; + hideTargets = new List { mainComponents, TopRightElements, rightSettings }; if (rulesetComponents != null) hideTargets.Add(rulesetComponents); @@ -228,7 +240,7 @@ namespace osu.Game.Screens.Play { if (e.NewValue) { - ModDisplay.FadeIn(200); + ModDisplay.FadeIn(1000, FADE_EASING); InputCountController.Margin = new MarginPadding(10) { Bottom = 30 }; } else @@ -239,6 +251,9 @@ namespace osu.Game.Screens.Play updateVisibility(); }, true); + + ModDisplay.ExpansionMode = ExpansionMode.AlwaysExpanded; + Scheduler.AddDelayed(() => ModDisplay.ExpansionMode = ExpansionMode.ExpandOnHover, 1200); } protected override void Update() @@ -265,18 +280,18 @@ namespace osu.Game.Screens.Play if (rulesetComponents != null) processDrawables(rulesetComponents); - if (lowestTopScreenSpaceRight.HasValue) - topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight); + if (lowestTopScreenSpaceRight.HasValue && DrawHeight - TopRightElements.DrawHeight > 0) + TopRightElements.Y = Math.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - TopRightElements.DrawHeight); else - topRightElements.Y = 0; + TopRightElements.Y = 0; - if (lowestTopScreenSpaceLeft.HasValue) - LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight); + if (lowestTopScreenSpaceLeft.HasValue && DrawHeight - LeaderboardFlow.DrawHeight > 0) + LeaderboardFlow.Y = Math.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight); else LeaderboardFlow.Y = 0; - if (highestBottomScreenSpace.HasValue) - bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); + if (highestBottomScreenSpace.HasValue && DrawHeight - bottomRightElements.DrawHeight > 0) + bottomRightElements.Y = BottomScoringElementsHeight = -Math.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); else bottomRightElements.Y = 0; @@ -389,8 +404,6 @@ namespace osu.Game.Screens.Play Origin = Anchor.TopRight, }; - protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); - public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) @@ -404,7 +417,7 @@ namespace osu.Game.Screens.Play case GlobalAction.HoldForHUD: holdingForHUD.Value = true; - return true; + return false; case GlobalAction.ToggleInGameInterface: switch (configVisibilityMode.Value) diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs index ad28e343ff..bef7362aa9 100644 --- a/osu.Game/Screens/Play/IGameplayClock.cs +++ b/osu.Game/Screens/Play/IGameplayClock.cs @@ -18,6 +18,11 @@ namespace osu.Game.Screens.Play /// double StartTime { get; } + /// + /// The time from which actual gameplay should start. When intro time is skipped, this will be the seeked location. + /// + double GameplayStartTime { get; } + /// /// All adjustments applied to this clock which come from mods. /// diff --git a/osu.Game/Screens/Play/KiaiGameplayFountains.cs b/osu.Game/Screens/Play/KiaiGameplayFountains.cs index fd9596c838..826c60c6cf 100644 --- a/osu.Game/Screens/Play/KiaiGameplayFountains.cs +++ b/osu.Game/Screens/Play/KiaiGameplayFountains.cs @@ -1,15 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osu.Game.Screens.Menu; @@ -22,6 +19,8 @@ namespace osu.Game.Screens.Play private Bindable kiaiStarFountains = null!; + private StarFountainSounds sounds = null!; + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { @@ -29,7 +28,7 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both; - Children = new[] + Children = new Drawable[] { leftFountain = new GameplayStarFountain { @@ -43,38 +42,35 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomRight, X = -75, }, + sounds = new StarFountainSounds(), }; } private bool isTriggered; - private double? lastTrigger; - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + protected override void Update() { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + base.Update(); if (!kiaiStarFountains.Value) return; - if (effectPoint.KiaiMode && !isTriggered) + if (EffectPoint.KiaiMode && !isTriggered) { - bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; + bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - EffectPoint.Time) < 500; if (isNearEffectPoint) Shoot(); } - isTriggered = effectPoint.KiaiMode; + isTriggered = EffectPoint.KiaiMode; } public void Shoot() { - if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500) - return; - leftFountain.Shoot(1); rightFountain.Shoot(-1); - lastTrigger = Clock.CurrentTime; + + sounds.Play(); } public partial class GameplayStarFountain : StarFountain diff --git a/osu.Game/Screens/Play/LetterboxOverlay.cs b/osu.Game/Screens/Play/LetterboxOverlay.cs new file mode 100644 index 0000000000..4c934f56cd --- /dev/null +++ b/osu.Game/Screens/Play/LetterboxOverlay.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play +{ + public partial class LetterboxOverlay : CompositeDrawable + { + public required BreakTracker BreakTracker { get; init; } + + private readonly Container fadeContainer; + + private readonly IBindable currentPeriod = new Bindable(); + + private static readonly Color4 transparent_black = new Color4(0, 0, 0, 0); + + public LetterboxOverlay() + { + RelativeSizeAxes = Axes.Both; + const float letterbox_height = 0.125f; + + InternalChild = fadeContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.Both, + Height = letterbox_height, + Colour = Color4.Black, + }, + new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = letterbox_height, + Colour = Color4.Black, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentPeriod.BindTo(BreakTracker.CurrentPeriod); + currentPeriod.BindValueChanged(updateDisplay, true); + } + + private void updateDisplay(ValueChangedEvent period) + { + FinishTransforms(true); + Scheduler.CancelDelayedTasks(); + + if (period.NewValue == null) + return; + + var b = period.NewValue.Value; + + using (BeginAbsoluteSequence(b.Start)) + { + fadeContainer.FadeIn(BreakOverlay.BREAK_FADE_DURATION); + using (BeginDelayedSequence(b.Duration - BreakOverlay.BREAK_FADE_DURATION)) + fadeContainer.FadeOut(BreakOverlay.BREAK_FADE_DURATION); + } + } + } +} diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index b2f0ae5561..07ecb5a5fb 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -12,6 +12,7 @@ using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; +using osu.Game.Storyboards; namespace osu.Game.Screens.Play { @@ -53,50 +54,47 @@ namespace osu.Game.Screens.Play private readonly Bindable playbackRateValid = new Bindable(true); - private readonly WorkingBeatmap beatmap; + private readonly IBeatmap beatmap; private Track track; - private readonly double skipTargetTime; - [Resolved] private MusicController musicController { get; set; } = null!; /// /// Create a new master gameplay clock container. /// - /// The beatmap to be used for time and metadata references. - /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. - public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) - : base(beatmap.Track, applyOffsets: true, requireDecoupling: true) + /// The beatmap to be used for time and metadata references. + /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. + public MasterGameplayClockContainer(WorkingBeatmap working, double gameplayStartTime) + : base(working.Track, applyOffsets: true, requireDecoupling: true) { - this.beatmap = beatmap; - this.skipTargetTime = skipTargetTime; + beatmap = working.Beatmap; + track = working.Track; - track = beatmap.Track; - - StartTime = findEarliestStartTime(); + GameplayStartTime = gameplayStartTime; + StartTime = findEarliestStartTime(gameplayStartTime, beatmap, working.Storyboard); } - private double findEarliestStartTime() + private static double findEarliestStartTime(double gameplayStartTime, IBeatmap beatmap, Storyboard storyboard) { // here we are trying to find the time to start playback from the "zero" point. // generally this is either zero, or some point earlier than zero in the case of storyboards, lead-ins etc. // start with the originally provided latest time (if before zero). - double time = Math.Min(0, skipTargetTime); + double time = Math.Min(0, gameplayStartTime); // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. // this is commonly used to display an intro before the audio track start. - double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; + double? firstStoryboardEvent = storyboard.EarliestEventTime; if (firstStoryboardEvent != null) time = Math.Min(time, firstStoryboardEvent.Value); // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. // this is not available as an option in the live editor but can still be applied via .osu editing. - double firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; - if (beatmap.BeatmapInfo.AudioLeadIn > 0) - time = Math.Min(time, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); + double firstHitObjectTime = beatmap.HitObjects.First().StartTime; + if (beatmap.AudioLeadIn > 0) + time = Math.Min(time, firstHitObjectTime - beatmap.AudioLeadIn); return time; } @@ -119,10 +117,10 @@ namespace osu.Game.Screens.Play /// public void Skip() { - if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME) + if (GameplayClock.CurrentTime > GameplayStartTime - MINIMUM_SKIP_TIME) return; - double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME; + double skipTarget = GameplayStartTime - MINIMUM_SKIP_TIME; if (StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros @@ -138,7 +136,7 @@ namespace osu.Game.Screens.Play { removeAdjustmentsFromTrack(); - track = new TrackVirtual(beatmap.Track.Length); + track = new TrackVirtual(track.Length); track.Seek(CurrentTime); if (IsRunning) track.Start(); @@ -187,7 +185,8 @@ namespace osu.Game.Screens.Play } else { - Logger.Log($"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}"); + Logger.Log( + $"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}"); } elapsedValidationTime = null; @@ -229,9 +228,8 @@ namespace osu.Game.Screens.Play removeAdjustmentsFromTrack(); } - ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo; + ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.ControlPointInfo; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => track.CurrentAmplitudes; IClock IBeatSyncProvider.Clock => this; - - ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 3a471acba4..18d17c1317 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -5,9 +5,12 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Platform; using osu.Game.Audio; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -31,14 +34,29 @@ namespace osu.Game.Screens.Play OnResume?.Invoke(); }; + private readonly IBindable windowActive = new Bindable(true); + + private float targetVolume => windowActive.Value && State.Value == Visibility.Visible ? 1.0f : 0; + [BackgroundDependencyLoader] - private void load() + private void load(GameHost? host) { AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) { Looping = true, Volume = { Value = 0 } }); + + if (host != null) + windowActive.BindTo(host.IsActive); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Schedule required because host.IsActive doesn't seem to always run on the update thread. + windowActive.BindValueChanged(_ => Schedule(() => pauseLoop.VolumeTo(targetVolume, 1000, Easing.Out))); } public void StopAllSamples() @@ -53,7 +71,7 @@ namespace osu.Game.Screens.Play { base.PopIn(); - pauseLoop.VolumeTo(1.0f, TRANSITION_DURATION, Easing.InQuint); + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.InQuint); pauseLoop.Play(); } @@ -61,7 +79,7 @@ namespace osu.Game.Screens.Play { base.PopOut(); - pauseLoop.VolumeTo(0, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); } public override bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 2d1f602832..a738a40993 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -15,7 +15,6 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; @@ -57,7 +56,7 @@ namespace osu.Game.Screens.Play /// public event Action OnGameplayStarted; - public override bool AllowBackButton => false; // handled by HoldForMenuButton + public override bool AllowUserExit => false; // handled by HoldForMenuButton protected override bool PlayExitSound => !isRestarting; @@ -69,6 +68,17 @@ namespace osu.Game.Screens.Play public override bool HideMenuCursorOnNonMouseInput => true; + public override bool RequiresPortraitOrientation + { + get + { + if (!LoadedBeatmapSuccessfully) + return false; + + return DrawableRuleset!.RequiresPortraitOrientation; + } + } + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). @@ -83,13 +93,11 @@ namespace osu.Game.Screens.Play /// protected virtual bool PauseOnFocusLost => true; - public Action RestartRequested; + public Action PrepareLoaderForRestart; private bool isRestarting; private bool skipExitTransition; - private Bindable mouseWheelDisabled; - private readonly Bindable storyboardReplacesBackground = new Bindable(); public IBindable LocalUserPlaying => localUserPlaying; @@ -228,8 +236,6 @@ namespace osu.Game.Screens.Play return; } - mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); - if (game != null) gameActive.BindTo(game.IsActive); @@ -251,7 +257,10 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(HealthProcessor); - InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); + InternalChildren = new Drawable[] + { + GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime), + }; AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); @@ -263,9 +272,10 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard, PlayingState)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); + GameplayClockContainer.Add(new GameplayScrollWheelHandling()); // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. @@ -323,6 +333,7 @@ namespace osu.Game.Screens.Play } dependencies.CacheAs(DrawableRuleset.FrameStableClock); + dependencies.CacheAs(DrawableRuleset.FrameStableClock); // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) @@ -439,6 +450,13 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), + new LetterboxOverlay + { + Clock = DrawableRuleset.FrameStableClock, + ProcessCustomClock = false, + BreakTracker = breakTracker, + Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0, + }, HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = @@ -457,7 +475,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre }, - BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) + BreakOverlay = new BreakOverlay(ScoreProcessor) { Clock = DrawableRuleset.FrameStableClock, ProcessCustomClock = false, @@ -646,7 +664,6 @@ namespace osu.Game.Screens.Play // import current score if possible. prepareAndImportScoreAsync(); - // Screen may not be current if a restart has been performed. if (this.IsCurrentScreen()) { skipExitTransition = skipTransition; @@ -657,6 +674,12 @@ namespace osu.Game.Screens.Play // - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance. this.Exit(); } + else + { + // May be restarting from results screen. + if (this.GetChildScreen() != null) + this.MakeCurrent(); + } return true; } @@ -719,12 +742,8 @@ namespace osu.Game.Screens.Play // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. musicController.Stop(); - if (RestartRequested != null) - { - skipExitTransition = quickRestart; - RestartRequested?.Invoke(quickRestart); - return true; - } + skipExitTransition = quickRestart; + PrepareLoaderForRestart?.Invoke(quickRestart); return PerformExit(quickRestart); } @@ -893,16 +912,6 @@ namespace osu.Game.Screens.Play }); } - protected override bool OnScroll(ScrollEvent e) - { - // During pause, allow global volume adjust regardless of settings. - if (GameplayClockContainer.IsPaused.Value) - return false; - - // Block global volume adjust if the user has asked for it (special case when holding "Alt"). - return mouseWheelDisabled.Value && !e.AltPressed; - } - #region Gameplay leaderboard protected readonly Bindable LeaderboardExpandedState = new BindableBool(); @@ -1031,7 +1040,7 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; protected bool PauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; + PlayingState.Value == LocalUserPlayingState.Playing && lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; /// /// A set of conditionals which defines whether the current game state and configuration allows for @@ -1267,11 +1276,7 @@ namespace osu.Game.Screens.Play /// /// The to be displayed in the results screen. /// The . - protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) - { - AllowRetry = true, - ShowUserStatistics = true, - }; + protected abstract ResultsScreen CreateResults(ScoreInfo score); private void fadeOut() { diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 0db96b71ad..bd4b62fd59 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -27,8 +27,8 @@ using osu.Game.Input; using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.Volume; using osu.Game.Performance; -using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Skinning; @@ -53,6 +53,9 @@ namespace osu.Game.Screens.Play public override bool? AllowGlobalTrackControl => false; + // this makes the game stay in portrait mode when restarting gameplay rather than switching back to landscape. + public override bool RequiresPortraitOrientation => CurrentPlayer?.RequiresPortraitOrientation == true; + public override float BackgroundParallaxAmount => quickRestart ? 0 : 1; // Here because IsHovered will not update unless we do so. @@ -79,8 +82,6 @@ namespace osu.Game.Screens.Play private FillFlowContainer disclaimers = null!; private OsuScrollContainer settingsScroll = null!; - private Bindable lastScore = null!; - private Bindable showStoryboards = null!; private bool backgroundBrightnessReduction; @@ -182,14 +183,13 @@ namespace osu.Game.Screens.Play { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); - lastScore = sessionStatics.GetBindable(Static.LastLocalUserScore); - showStoryboards = config.GetBindable(OsuSetting.ShowStoryboard); const float padding = 25; InternalChildren = new Drawable[] { + new GlobalScrollAdjustsVolume(), (content = new LogoTrackingContainer { Anchor = Anchor.Centre, @@ -242,7 +242,7 @@ namespace osu.Game.Screens.Play sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) }; - if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) + if (Beatmap.Value.Beatmap.EpilepsyWarning) { disclaimers.Add(epilepsyWarning = new PlayerLoaderDisclaimer(PlayerLoaderStrings.EpilepsyWarningTitle, PlayerLoaderStrings.EpilepsyWarningContent)); } @@ -352,8 +352,6 @@ namespace osu.Game.Screens.Play highPerformanceSession?.Dispose(); highPerformanceSession = null; - lastScore.Value = null; - return base.OnExiting(e); } @@ -457,7 +455,7 @@ namespace osu.Game.Screens.Play CurrentPlayer = createPlayer(); CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; CurrentPlayer.RestartCount = restartCount++; - CurrentPlayer.RestartRequested = restartRequested; + CurrentPlayer.PrepareLoaderForRestart = prepareForRestart; LoadTask = LoadComponentAsync(CurrentPlayer, _ => { @@ -470,13 +468,11 @@ namespace osu.Game.Screens.Play { } - private void restartRequested(bool quickRestartRequested) + private void prepareForRestart(bool quickRestartRequested) { quickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; - - this.MakeCurrent(); } private void contentIn(double delayBeforeSideDisplays = 0) @@ -485,6 +481,8 @@ namespace osu.Game.Screens.Play if (quickRestart) { + BackButtonVisibility.Value = false; + // A quick restart starts by triggering a fade to black AddInternal(quickRestartBlackLayer = new Box { @@ -503,6 +501,8 @@ namespace osu.Game.Screens.Play .Delay(quick_restart_initial_delay) .ScaleTo(1) .FadeInFromZero(500, Easing.OutQuint); + + this.Delay(quick_restart_initial_delay).Schedule(() => BackButtonVisibility.Value = true); } else { @@ -666,8 +666,6 @@ namespace osu.Game.Screens.Play private partial class MutedNotification : SimpleNotification { - public override bool IsImportant => true; - public MutedNotification() { Text = NotificationsStrings.GameVolumeTooLow; @@ -719,8 +717,6 @@ namespace osu.Game.Screens.Play private partial class BatteryWarningNotification : SimpleNotification { - public override bool IsImportant => true; - public BatteryWarningNotification() { Text = NotificationsStrings.BatteryLow; diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 74b887481f..cef5884d39 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -15,6 +15,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -36,6 +37,8 @@ namespace osu.Game.Screens.Play.PlayerSettings { public Bindable ReferenceScore { get; } = new Bindable(); + private Bindable lastAppliedScore { get; } = new Bindable(); + public BindableDouble Current { get; } = new BindableDouble { MinValue = -50, @@ -100,6 +103,12 @@ namespace osu.Game.Screens.Play.PlayerSettings }; } + [BackgroundDependencyLoader] + private void load(SessionStatics statics) + { + statics.BindWith(Static.LastAppliedOffsetScore, lastAppliedScore); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -112,7 +121,11 @@ namespace osu.Game.Screens.Play.PlayerSettings // At the point we reach here, it's not guaranteed that all realm writes have taken place (there may be some in-flight). // We are only aware of writes that originated from our own flow, so if we do see one that's active we can avoid handling the feedback value arriving. if (realmWriteTask == null) + { + Current.Disabled = false; Current.Value = val; + Current.Disabled = allowOffsetAdjust; + } if (realmWriteTask?.IsCompleted == true) { @@ -125,15 +138,15 @@ namespace osu.Game.Screens.Play.PlayerSettings ReferenceScore.BindValueChanged(scoreChanged, true); } + // the last play graph is relative to the offset at the point of the last play, so we need to factor that out for some usages. + private double adjustmentSinceLastPlay => lastPlayBeatmapOffset - Current.Value; + private void currentChanged(ValueChangedEvent offset) { Scheduler.AddOnce(updateOffset); void updateOffset() { - // the last play graph is relative to the offset at the point of the last play, so we need to factor that out. - double adjustmentSinceLastPlay = lastPlayBeatmapOffset - Current.Value; - // Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks). lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay); @@ -144,11 +157,6 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - if (useAverageButton != null) - { - useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2); - } - realmWriteTask = realm.WriteAsync(r => { var setInfo = r.Find(beatmap.Value.BeatmapSetInfo.ID); @@ -156,13 +164,14 @@ namespace osu.Game.Screens.Play.PlayerSettings if (setInfo == null) // only the case for tests. return; - // Apply to all difficulties in a beatmap set for now (they generally always share timing). + // Apply to all difficulties in a beatmap set if they have the same audio + // (they generally always share timing). foreach (var b in setInfo.Beatmaps) { BeatmapUserSettings userSettings = b.UserSettings; double val = Current.Value; - if (userSettings.Offset != val) + if (userSettings.Offset != val && b.AudioEquals(beatmap.Value.BeatmapInfo)) userSettings.Offset = val; } }); @@ -176,6 +185,9 @@ namespace osu.Game.Screens.Play.PlayerSettings if (score.NewValue == null) return; + if (score.NewValue.Equals(lastAppliedScore.Value)) + return; + if (!score.NewValue.BeatmapInfo.AsNonNull().Equals(beatmap.Value.BeatmapInfo)) return; @@ -230,8 +242,14 @@ namespace osu.Game.Screens.Play.PlayerSettings useAverageButton = new SettingsButton { Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, - Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage, - Enabled = { Value = !Precision.AlmostEquals(lastPlayAverage, 0, Current.Precision / 2) } + Action = () => + { + if (Current.Disabled) + return; + + Current.Value = lastPlayBeatmapOffset - lastPlayAverage; + lastAppliedScore.Value = ReferenceScore.Value; + }, }, globalOffsetText = new LinkFlowContainer { @@ -257,20 +275,42 @@ namespace osu.Game.Screens.Play.PlayerSettings beatmapOffsetSubscription?.Dispose(); } + protected override void Update() + { + base.Update(); + + bool allow = allowOffsetAdjust; + + if (useAverageButton != null) + useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2); + + Current.Disabled = !allow; + } + + private bool allowOffsetAdjust + { + get + { + // General limitations to ensure players don't do anything too weird. + // These match stable for now. + if (player is SubmittingPlayer) + { + Debug.Assert(gameplayClock != null); + + // TODO: the blocking conditions should probably display a message. + if (!player.IsBreakTime.Value && gameplayClock.CurrentTime - gameplayClock.GameplayStartTime > 10000) + return false; + + if (gameplayClock.IsPaused.Value) + return false; + } + + return true; + } + } + public bool OnPressed(KeyBindingPressEvent e) { - // General limitations to ensure players don't do anything too weird. - // These match stable for now. - if (player is SubmittingPlayer) - { - // TODO: the blocking conditions should probably display a message. - if (player?.IsBreakTime.Value == false && gameplayClock?.CurrentTime - gameplayClock?.StartTime > 10000) - return false; - - if (gameplayClock?.IsPaused.Value == true) - return false; - } - // To match stable, this should adjust by 5 ms, or 1 ms when holding alt. // But that is hard to make work with global actions due to the operating mode. // Let's use the more precise as a default for now. @@ -279,11 +319,13 @@ namespace osu.Game.Screens.Play.PlayerSettings switch (e.Action) { case GlobalAction.IncreaseOffset: - Current.Value += amount; + if (!Current.Disabled) + Current.Value += amount; return true; case GlobalAction.DecreaseOffset: - Current.Value -= amount; + if (!Current.Disabled) + Current.Value -= amount; return true; } diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 0c125264a1..ba572f6014 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -32,12 +32,16 @@ namespace osu.Game.Screens.Play private readonly bool replayIsFailedScore; + private PlaybackSettings playbackSettings; + protected override UserActivity InitialActivity => new UserActivity.WatchingReplay(Score.ScoreInfo); + private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); + // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) protected override bool CheckModsAllowFailure() { - if (!replayIsFailedScore && !GameplayState.Mods.OfType().Any()) + if (!replayIsFailedScore && !isAutoplayPlayback) return false; return base.CheckModsAllowFailure(); @@ -71,7 +75,7 @@ namespace osu.Game.Screens.Play if (!LoadedBeatmapSuccessfully) return; - var playbackSettings = new PlaybackSettings + playbackSettings = new PlaybackSettings { Depth = float.MaxValue, Expanded = { BindTarget = config.GetBindable(OsuSetting.ReplayPlaybackControlsExpanded) } @@ -102,7 +106,12 @@ namespace osu.Game.Screens.Play Scores = { BindTarget = LeaderboardScores } }; - protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score); + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) + { + // Only show the relevant button otherwise things look silly. + AllowWatchingReplay = !isAutoplayPlayback, + AllowRetry = isAutoplayPlayback, + }; public bool OnPressed(KeyBindingPressEvent e) { @@ -117,11 +126,11 @@ namespace osu.Game.Screens.Play return true; case GlobalAction.SeekReplayBackward: - SeekInDirection(-5); + SeekInDirection(-5 * (float)playbackSettings.UserPlaybackRate.Value); return true; case GlobalAction.SeekReplayForward: - SeekInDirection(5); + SeekInDirection(5 * (float)playbackSettings.UserPlaybackRate.Value); return true; case GlobalAction.TogglePauseReplay: diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 24c5b2c3d4..dc3e5f08ac 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -21,6 +21,7 @@ using osu.Game.Online.Rooms; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { @@ -152,7 +153,7 @@ namespace osu.Game.Screens.Play Logger.Log($"Please ensure that you are using the latest version of the official game releases.\n\n{whatWillHappen}", level: LogLevel.Important); break; - case @"invalid beatmap_hash": + case @"invalid or missing beatmap_hash": Logger.Log($"This beatmap does not match the online version. Please update or redownload it.\n\n{whatWillHappen}", level: LogLevel.Important); break; @@ -284,6 +285,13 @@ namespace osu.Game.Screens.Play return Task.CompletedTask; } + // zero scores should also never be submitted. + if (score.ScoreInfo.TotalScore == 0) + { + Logger.Log("Zero score, skipping score submission"); + return Task.CompletedTask; + } + // mind the timing of this. // once `scoreSubmissionSource` is created, it is presumed that submission is taking place in the background, // so all exceptional circumstances that would disallow submission must be handled above. @@ -316,5 +324,11 @@ namespace osu.Game.Screens.Play api.Queue(request); return scoreSubmissionSource.Task; } + + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) + { + AllowRetry = true, + IsLocalPlay = true, + }; } } diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index cfb6465e62..fbc0fd8a70 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -11,13 +11,15 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -67,7 +69,7 @@ namespace osu.Game.Screens.Ranking.Contracted Colour = Color4.Black.Opacity(0.25f), Type = EdgeEffectType.Shadow, Radius = 1, - Offset = new Vector2(0, 4) + Offset = new Vector2(0, 2) }, Children = new Drawable[] { @@ -100,18 +102,16 @@ namespace osu.Game.Screens.Ranking.Contracted CornerRadius = 20, EdgeEffect = new EdgeEffectParameters { - Colour = Color4.Black.Opacity(0.25f), + Colour = Color4.Black.Opacity(0.15f), Type = EdgeEffectType.Shadow, Radius = 8, - Offset = new Vector2(0, 4), + Offset = new Vector2(0, 1), } }, - new OsuSpriteText + new ClickableUsername(score.User) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = score.RealmUser.Username, - Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold) }, new FillFlowContainer { @@ -134,14 +134,33 @@ namespace osu.Game.Screens.Ranking.Contracted createStatistic(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, $"{score.Accuracy.FormatAccuracy()}"), } }, - new ModFlowDisplay + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Current = { Value = score.Mods }, - IconScale = 0.5f, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(3), + ChildrenEnumerable = + [ + new DifficultyIcon(score.BeatmapInfo!, score.Ruleset) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + Margin = new MarginPadding { Right = 2 } + }, + .. + score.Mods.AsOrdered().Select(m => new ModIcon(m) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(0.3f), + Margin = new MarginPadding { Top = -6 } + }) + ] } } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index 319a87fdfc..f6cf71d8a6 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -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.Audio; @@ -91,6 +90,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; + [Resolved] + private ResultsScreen? resultsScreen { get; set; } + private CircularProgress accuracyCircle = null!; private GradedCircles gradedCircles = null!; private Container badges = null!; @@ -101,7 +103,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private PoolableSkinnableSample? badgeMaxSound; private PoolableSkinnableSample? swooshUpSound; private PoolableSkinnableSample? rankImpactSound; - private PoolableSkinnableSample? rankApplauseSound; private readonly Bindable tickPlaybackRate = new Bindable(); @@ -197,15 +198,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy if (withFlair) { - var applauseSamples = new List { applauseSampleName }; - if (score.Rank >= ScoreRank.B) - // when rank is B or higher, play legacy applause sample on legacy skins. - applauseSamples.Insert(0, @"applause"); - AddRangeInternal(new Drawable[] { rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)), - rankApplauseSound = new PoolableSkinnableSample(new SampleInfo(applauseSamples.ToArray())), scoreTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/score-tick")), badgeTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink")), badgeMaxSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink-max")), @@ -333,16 +328,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy }); const double applause_pre_delay = 545f; - const double applause_volume = 0.8f; using (BeginDelayedSequence(applause_pre_delay)) - { - Schedule(() => - { - rankApplauseSound!.VolumeTo(applause_volume); - rankApplauseSound!.Play(); - }); - } + Schedule(() => resultsScreen?.PlayApplause(score.Rank)); } } @@ -384,34 +372,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy } } - private string applauseSampleName - { - get - { - switch (score.Rank) - { - default: - case ScoreRank.D: - return @"Results/applause-d"; - - case ScoreRank.C: - return @"Results/applause-c"; - - case ScoreRank.B: - return @"Results/applause-b"; - - case ScoreRank.A: - return @"Results/applause-a"; - - case ScoreRank.S: - case ScoreRank.SH: - case ScoreRank.X: - case ScoreRank.XH: - return @"Results/applause-s"; - } - } - } - private string impactSampleName { get diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index d1dc1a81db..445d219c7f 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -16,6 +14,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -41,11 +40,10 @@ namespace osu.Game.Screens.Ranking.Expanded private readonly List statisticDisplays = new List(); - private FillFlowContainer starAndModDisplay; - private RollingCounter scoreCounter; + private RollingCounter scoreCounter = null!; [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; /// /// Creates a new . @@ -64,12 +62,19 @@ namespace osu.Game.Screens.Ranking.Expanded } [BackgroundDependencyLoader] - private void load(BeatmapDifficultyCache beatmapDifficultyCache) + private void load(RealmAccess realmAccess, BeatmapDifficultyCache beatmapDifficultyCache) { var beatmap = score.BeatmapInfo!; var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; + StarDifficulty starDifficulty = new StarDifficulty(beatmap.StarRating, 0); + + // In some cases, the beatmap ferried through ScoreInfo actually represents an online beatmap. + // If it isn't, we may be able to compute a more accurate difficulty from the ruleset and mods. + if (realmAccess.Run(r => r.Find(score.BeatmapInfo!.ID)) != null) + starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods).GetResultSafely() ?? starDifficulty; + var topStatistics = new List { new AccuracyStatistic(score.Accuracy), @@ -101,22 +106,7 @@ namespace osu.Game.Screens.Ranking.Expanded Direction = FillDirection.Vertical, Children = new Drawable[] { - new TruncatingSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), - Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - }, - new TruncatingSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - }, + new ClickableMetadata(beatmap.OnlineID, metadata), new Container { Anchor = Anchor.TopCentre, @@ -139,12 +129,35 @@ namespace osu.Game.Screens.Ranking.Expanded Alpha = 0, AlwaysPresent = true }, - starAndModDisplay = new FillFlowContainer + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new StarRatingDisplay(starDifficulty) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new DifficultyIcon(beatmap, score.Ruleset) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + ExpansionMode = ExpansionMode.AlwaysExpanded, + Scale = new Vector2(0.5f), + Current = { Value = score.Mods } + } + } }, new FillFlowContainer { @@ -225,29 +238,6 @@ namespace osu.Game.Screens.Ranking.Expanded if (score.Date != default) AddInternal(new PlayedOnText(score.Date)); - - var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); - - if (starDifficulty != null) - { - starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }); - } - - if (score.Mods.Any()) - { - starAndModDisplay.Add(new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - ExpansionMode = ExpansionMode.AlwaysExpanded, - Scale = new Vector2(0.5f), - Current = { Value = score.Mods } - }); - } } protected override void LoadComplete() @@ -311,5 +301,47 @@ namespace osu.Game.Screens.Ranking.Expanded time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); } } + + internal partial class ClickableMetadata : OsuHoverContainer + { + [Resolved] + private OsuGame? game { get; set; } + + public ClickableMetadata(int beatmapId, IBeatmapMetadataInfo metadata) + { + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TruncatingSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + }, + new TruncatingSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + } + } + }; + + if (beatmapId > 0) + Action = () => game?.ShowBeatmap(beatmapId); + } + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs index c834d541eb..b50996154b 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -8,8 +8,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users.Drawables; using osuTK; @@ -62,12 +60,10 @@ namespace osu.Game.Screens.Ranking.Expanded CornerExponent = 2.5f, Masking = true, }, - new OsuSpriteText + new ClickableUsername(user) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = user.Username, - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold) } } }; diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 0209fbd39c..6da731588f 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -17,22 +18,25 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; -using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osu.Game.Overlays; +using osu.Game.Overlays.Volume; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking { + [Cached] public abstract partial class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { protected const float BACKGROUND_BLUR = 20; @@ -55,15 +59,14 @@ namespace osu.Game.Screens.Ranking [Resolved] private Player? player { get; set; } - [Resolved] - private IAPIProvider api { get; set; } = null!; + private bool skipExitTransition; protected StatisticsPanel StatisticsPanel { get; private set; } = null!; private Drawable bottomPanel = null!; private Container detachedPanelContainer = null!; - private bool lastFetchCompleted; + private Task lastFetchTask = Task.CompletedTask; /// /// Whether the user can retry the beatmap from the results screen. @@ -76,11 +79,10 @@ namespace osu.Game.Screens.Ranking public bool AllowWatchingReplay { get; init; } = true; /// - /// Whether the user's personal statistics should be shown on the extended statistics panel - /// after clicking the score panel associated with the being presented. - /// Requires to be present. + /// Whether the provided score is for a local user's play. + /// This will trigger elements like the user's ranking to display. /// - public bool ShowUserStatistics { get; init; } + public bool IsLocalPlay { get; init; } private Sample? popInSample; @@ -117,11 +119,13 @@ namespace osu.Game.Screens.Ranking RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - StatisticsPanel = createStatisticsPanel().With(panel => + new GlobalScrollAdjustsVolume(), + StatisticsPanel = new StatisticsPanel { - panel.RelativeSizeAxes = Axes.Both; - panel.Score.BindTarget = SelectedScore; - }), + RelativeSizeAxes = Axes.Both, + Score = { BindTarget = SelectedScore }, + AchievedScore = IsLocalPlay && Score != null ? Score : null, + }, ScorePanelList = new ScorePanelList { RelativeSizeAxes = Axes.Both, @@ -184,6 +188,8 @@ namespace osu.Game.Screens.Ranking Scheduler.AddDelayed(() => OverlayActivationMode.Value = OverlayActivation.All, shouldFlair ? AccuracyCircle.TOTAL_DURATION + 1000 : 0); } + bool allowHotkeyRetry = false; + if (AllowWatchingReplay) { buttons.Add(new ReplayDownloadButton(SelectedScore.Value) @@ -191,18 +197,29 @@ namespace osu.Game.Screens.Ranking Score = { BindTarget = SelectedScore }, Width = 300 }); + + // for simplicity, only allow this when coming from a replay player where we know the replay is ready to be played. + // + // if we show it in all cases, consider the case where a user comes from song select and potentially has to download + // the replay before it can be played back. it wouldn't flow well with the quick retry in such a case. + allowHotkeyRetry = player is ReplayPlayer; } if (player != null && AllowRetry) { buttons.Add(new RetryButton { Width = 300 }); + allowHotkeyRetry = true; + } + if (allowHotkeyRetry) + { AddInternal(new HotkeyRetryOverlay { Action = () => { if (!this.IsCurrentScreen()) return; + skipExitTransition = true; player?.Restart(true); }, }); @@ -219,74 +236,158 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - var req = FetchScores(fetchScoresCallback); - - if (req != null) - api.Queue(req); - StatisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + + fetchScores(null); } protected override void Update() { base.Update(); - if (lastFetchCompleted) + if (ScorePanelList.IsScrolledToStart) + fetchScores(-1); + else if (ScorePanelList.IsScrolledToEnd) + fetchScores(1); + } + + #region Applause + + private PoolableSkinnableSample? rankApplauseSound; + + public void PlayApplause(ScoreRank rank) + { + const double applause_volume = 0.8f; + + if (!this.IsCurrentScreen()) + return; + + rankApplauseSound?.Dispose(); + + var applauseSamples = new List(); + + if (rank >= ScoreRank.B) + // when rank is B or higher, play legacy applause sample on legacy skins. + applauseSamples.Insert(0, @"applause"); + + switch (rank) { - APIRequest? nextPageRequest = null; + default: + case ScoreRank.D: + applauseSamples.Add(@"Results/applause-d"); + break; - if (ScorePanelList.IsScrolledToStart) - nextPageRequest = FetchNextPage(-1, fetchScoresCallback); - else if (ScorePanelList.IsScrolledToEnd) - nextPageRequest = FetchNextPage(1, fetchScoresCallback); + case ScoreRank.C: + applauseSamples.Add(@"Results/applause-c"); + break; - if (nextPageRequest != null) - { - lastFetchCompleted = false; - api.Queue(nextPageRequest); - } + case ScoreRank.B: + applauseSamples.Add(@"Results/applause-b"); + break; + + case ScoreRank.A: + applauseSamples.Add(@"Results/applause-a"); + break; + + case ScoreRank.S: + case ScoreRank.SH: + case ScoreRank.X: + case ScoreRank.XH: + applauseSamples.Add(@"Results/applause-s"); + break; } + + LoadComponentAsync(rankApplauseSound = new PoolableSkinnableSample(new SampleInfo(applauseSamples.ToArray())), s => + { + if (!this.IsCurrentScreen() || s != rankApplauseSound) + return; + + AddInternal(rankApplauseSound); + + rankApplauseSound.VolumeTo(applause_volume); + rankApplauseSound.Play(); + }); + } + + #endregion + + /// + /// Fetches the next page of scores in the given direction. + /// + /// The direction, or null to fetch any scores. + private void fetchScores(int? direction) + { + Debug.Assert(direction == null || direction == -1 || direction == 1); + + if (!lastFetchTask.IsCompleted) + return; + + lastFetchTask = Task.Run(async () => + { + ScoreInfo[] scores; + + switch (direction) + { + default: + scores = await FetchScores().ConfigureAwait(false); + break; + + case -1: + case 1: + scores = await FetchNextPage(direction.Value).ConfigureAwait(false); + break; + } + + await addScores(scores).ConfigureAwait(false); + }); } /// /// Performs a fetch/refresh of scores to be displayed. /// - /// A callback which should be called when fetching is completed. Scheduling is not required. - /// An responsible for the fetch operation. This will be queued and performed automatically. - protected virtual APIRequest? FetchScores(Action> scoresCallback) => null; + protected virtual Task FetchScores() => Task.FromResult([]); /// - /// Performs a fetch of the next page of scores. This is invoked every frame until a non-null is returned. + /// Performs a fetch of the next page of scores. This is invoked every frame. /// /// The fetch direction. -1 to fetch scores greater than the current start of the list, and 1 to fetch scores lower than the current end of the list. - /// A callback which should be called when fetching is completed. Scheduling is not required. - /// An responsible for the fetch operation. This will be queued and performed automatically. - protected virtual APIRequest? FetchNextPage(int direction, Action> scoresCallback) => null; + protected virtual Task FetchNextPage(int direction) => Task.FromResult([]); - /// - /// Creates the to be used to display extended information about scores. - /// - private StatisticsPanel createStatisticsPanel() + private Task addScores(ScoreInfo[] scores) { - return ShowUserStatistics && Score != null - ? new UserStatisticsPanel(Score) - : new StatisticsPanel(); + var tcs = new TaskCompletionSource(); + + Schedule(() => + { + foreach (var s in scores) + { + var panel = ScorePanelList.AddScore(s); + if (detachedPanel != null) + panel.Alpha = 0; + } + + // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. + Schedule(() => tcs.SetResult()); + + if (ScorePanelList.IsEmpty) + { + // This can happen if for example a beatmap that is part of a playlist hasn't been played yet. + VerticalScrollContent.Add(new MessagePlaceholder(LeaderboardStrings.NoRecordsYet)); + } + + OnScoresAdded(scores); + }); + + return tcs.Task; } - private void fetchScoresCallback(IEnumerable scores) => Schedule(() => + /// + /// Invoked after online scores are fetched and added to the list. + /// + /// The scores that were added. + protected virtual void OnScoresAdded(ScoreInfo[] scores) { - foreach (var s in scores) - addScore(s); - - // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. - Schedule(() => lastFetchCompleted = true); - - if (ScorePanelList.IsEmpty) - { - // This can happen if for example a beatmap that is part of a playlist hasn't been played yet. - VerticalScrollContent.Add(new MessagePlaceholder(LeaderboardStrings.NoRecordsYet)); - } - }); + } public override void OnEntering(ScreenTransitionEvent e) { @@ -313,7 +414,10 @@ namespace osu.Game.Screens.Ranking // HitObject references from HitEvent. Score?.HitEvents.Clear(); - this.FadeOut(100); + if (!skipExitTransition) + this.FadeOut(100); + + rankApplauseSound?.Stop(); return false; } @@ -328,14 +432,6 @@ namespace osu.Game.Screens.Ranking return false; } - private void addScore(ScoreInfo score) - { - var panel = ScorePanelList.AddScore(score); - - if (detachedPanel != null) - panel.Alpha = 0; - } - private ScorePanel? detachedPanel; private void onStatisticsStateChanged(ValueChangedEvent state) @@ -422,12 +518,24 @@ namespace osu.Game.Screens.Ranking { } + protected override bool OnScroll(ScrollEvent e) + { + // Match stable behaviour of only alt-scroll adjusting volume. + // This is the same behaviour as the song selection screen. + if (!e.CurrentState.Keyboard.AltPressed) + return true; + + return base.OnScroll(e); + } + protected partial class VerticalScrollContainer : OsuScrollContainer { protected override Container Content => content; private readonly Container content; + protected override bool OnScroll(ScrollEvent e) => !e.ControlPressed && !e.AltPressed && !e.ShiftPressed && !e.SuperPressed; + public VerticalScrollContainer() { Masking = false; diff --git a/osu.Game/Screens/Ranking/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs index d977f25323..8b4f3ca14c 100644 --- a/osu.Game/Screens/Ranking/RetryButton.cs +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -38,8 +38,6 @@ namespace osu.Game.Screens.Ranking Icon = FontAwesome.Solid.Redo, }, }; - - TooltipText = "retry"; } [BackgroundDependencyLoader] @@ -48,7 +46,14 @@ namespace osu.Game.Screens.Ranking background.Colour = colours.Green; if (player != null) + { + TooltipText = player is ReplayPlayer ? "replay" : "retry"; Action = () => player.Restart(); + } + else + { + TooltipText = "retry"; + } } } } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index e711bed729..b0e1c89121 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -334,7 +334,7 @@ namespace osu.Game.Screens.Ranking private partial class Scroll : OsuScrollContainer { - public new float Target => base.Target; + public new double Target => base.Target; public Scroll() : base(Direction.Horizontal) @@ -344,7 +344,7 @@ namespace osu.Game.Screens.Ranking /// /// The target that will be scrolled to instantaneously next frame. /// - public float? InstantScrollTarget; + public double? InstantScrollTarget; protected override void UpdateAfterChildren() { diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 33b4bf976b..3486d81e8a 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -4,12 +4,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -22,21 +24,60 @@ namespace osu.Game.Screens.Ranking [Resolved] private RulesetStore rulesets { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + public SoloResultsScreen(ScoreInfo score) : base(score) { } - protected override APIRequest? FetchScores(Action> scoresCallback) + protected override async Task FetchScores() { Debug.Assert(Score != null); if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) - return null; + return []; + + var requestTaskSource = new TaskCompletionSource(); getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback.Invoke(r.Scores.Where(s => !s.MatchesOnlineID(Score)).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); - return getScoreRequest; + getScoreRequest.Success += requestTaskSource.SetResult; + getScoreRequest.Failure += requestTaskSource.SetException; + api.Queue(getScoreRequest); + + try + { + var scores = await requestTaskSource.Task.ConfigureAwait(false); + var toDisplay = new List(); + + for (int i = 0; i < scores.Scores.Count; ++i) + { + var score = scores.Scores[i]; + int position = i + 1; + + if (score.MatchesOnlineID(Score)) + { + // we don't want to add the same score twice, but also setting any properties of `Score` this late will have no visible effect, + // so we have to fish out the actual drawable panel and set the position to it directly. + var panel = ScorePanelList.GetPanelForScore(Score); + Score.Position = panel.ScorePosition.Value = position; + } + else + { + var converted = score.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo); + converted.Position = position; + toDisplay.Add(converted); + } + } + + return toDisplay.ToArray(); + } + catch (Exception ex) + { + Logger.Log($"Failed to fetch scores (beatmap: {Score.BeatmapInfo}, ruleset: {Score.Ruleset}): {ex}"); + return []; + } } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index f9f5254bc2..9ead9ce91c 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -14,10 +14,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics.User; using osuTK; namespace osu.Game.Screens.Ranking.Statistics @@ -28,11 +31,21 @@ namespace osu.Game.Screens.Ranking.Statistics public readonly Bindable Score = new Bindable(); + /// + /// The score which was achieved by the local user. + /// If this is set to a non-null score, an component will be displayed showing changes to the local user's ranking and statistics + /// when a statistics update related to this score is received from spectator server. + /// + public ScoreInfo? AchievedScore { get; init; } + protected override bool StartHidden => true; [Resolved] private BeatmapManager beatmapManager { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + private readonly Container content; private readonly LoadingSpinner spinner; @@ -97,7 +110,7 @@ namespace osu.Game.Screens.Ranking.Statistics bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()); + var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()).ToArray(); if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents)) { @@ -199,8 +212,59 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// The score to create the rows for. /// The beatmap on which the score was set. - protected virtual ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) - => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); + protected virtual IEnumerable CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) + { + foreach (var statistic in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap)) + yield return statistic; + + if (AchievedScore != null + && newScore.UserID > 1 + && newScore.UserID == AchievedScore.UserID + && newScore.OnlineID > 0 + && newScore.OnlineID == AchievedScore.OnlineID) + { + yield return new StatisticItem("Overall Ranking", () => new OverallRanking(newScore) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + if (AchievedScore != null + && newScore.BeatmapInfo!.OnlineID > 0 + && api.IsLoggedIn) + { + string? preventTaggingReason = null; + + // We may want to iterate on the following conditions further in the future + + if (AchievedScore.Ruleset.OnlineID != AchievedScore.BeatmapInfo!.Ruleset.OnlineID) + preventTaggingReason = "Play the beatmap in its original ruleset to contribute to beatmap tags!"; + else if (AchievedScore.Rank < ScoreRank.C) + preventTaggingReason = "Set a better score to contribute to beatmap tags!"; + + if (preventTaggingReason == null) + { + yield return new StatisticItem("Tag the beatmap!", () => new UserTagControl(newScore.BeatmapInfo) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + else + { + yield return new StatisticItem("Tag the beatmap!", () => new OsuTextFlowContainer(cp => cp.Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.SemiBold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.Centre, + Text = preventTaggingReason, + }); + } + } + } protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs index 171a3f0f65..9d0a511f5a 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs @@ -5,8 +5,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Statistics.User { @@ -14,13 +16,21 @@ namespace osu.Game.Screens.Ranking.Statistics.User { private const float transition_duration = 300; - public Bindable StatisticsUpdate { get; } = new Bindable(); + public Bindable DisplayedUpdate { get; } = new Bindable(); + private readonly IBindable latestGlobalStatisticsUpdate = new Bindable(); + + private readonly ScoreInfo scoreInfo; private LoadingLayer loadingLayer = null!; private GridContainer content = null!; + public OverallRanking(ScoreInfo scoreInfo) + { + this.scoreInfo = scoreInfo; + } + [BackgroundDependencyLoader] - private void load() + private void load(UserStatisticsWatcher? userStatisticsWatcher) { AutoSizeAxes = Axes.Y; AutoSizeEasing = Easing.OutQuint; @@ -55,34 +65,44 @@ namespace osu.Game.Screens.Ranking.Statistics.User { new Drawable[] { - new GlobalRankChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new GlobalRankChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, }, - new Drawable[] { }, + [], new Drawable[] { - new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new AccuracyChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new AccuracyChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, }, - new Drawable[] { }, + [], new Drawable[] { - new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new TotalScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new TotalScoreChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, } } } }; + + if (userStatisticsWatcher != null) + { + latestGlobalStatisticsUpdate.BindTo(userStatisticsWatcher.LatestUpdate); + latestGlobalStatisticsUpdate.BindValueChanged(update => + { + if (update.NewValue?.Score.MatchesOnlineID(scoreInfo) == true) + DisplayedUpdate.Value = update.NewValue; + }, true); + } } protected override void LoadComplete() { base.LoadComplete(); - StatisticsUpdate.BindValueChanged(onUpdateReceived, true); + DisplayedUpdate.BindValueChanged(onUpdateReceived, true); FinishTransforms(true); } diff --git a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs deleted file mode 100644 index 86fed4a9bb..0000000000 --- a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Extensions; -using osu.Game.Online; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Statistics.User; - -namespace osu.Game.Screens.Ranking.Statistics -{ - public partial class UserStatisticsPanel : StatisticsPanel - { - private readonly ScoreInfo achievedScore; - - internal readonly Bindable DisplayedUserStatisticsUpdate = new Bindable(); - - private IBindable latestGlobalStatisticsUpdate = null!; - - public UserStatisticsPanel(ScoreInfo achievedScore) - { - this.achievedScore = achievedScore; - } - - [BackgroundDependencyLoader] - private void load(UserStatisticsWatcher? userStatisticsWatcher) - { - if (userStatisticsWatcher != null) - { - latestGlobalStatisticsUpdate = userStatisticsWatcher.LatestUpdate.GetBoundCopy(); - latestGlobalStatisticsUpdate.BindValueChanged(update => - { - if (update.NewValue?.Score.MatchesOnlineID(achievedScore) == true) - DisplayedUserStatisticsUpdate.Value = update.NewValue; - }, true); - } - } - - protected override ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) - { - var items = base.CreateStatisticItems(newScore, playableBeatmap); - - if (newScore.UserID > 1 - && newScore.UserID == achievedScore.UserID - && newScore.OnlineID > 0 - && newScore.OnlineID == achievedScore.OnlineID) - { - items = items.Append(new StatisticItem("Overall Ranking", () => new OverallRanking - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - StatisticsUpdate = { BindTarget = DisplayedUserStatisticsUpdate } - })).ToArray(); - } - - return items; - } - } -} diff --git a/osu.Game/Screens/Ranking/UserTag.cs b/osu.Game/Screens/Ranking/UserTag.cs new file mode 100644 index 0000000000..d44e531330 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTag.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Ranking +{ + public record UserTag + { + public long Id { get; } + public string Name { get; } + public string Description { get; } + + public BindableInt VoteCount { get; } = new BindableInt(); + public BindableBool Voted { get; } = new BindableBool(); + + public UserTag(APITag tag) + { + Id = tag.Id; + Name = tag.Name; + Description = tag.Description; + } + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs new file mode 100644 index 0000000000..ae4a918ae5 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -0,0 +1,606 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Ranking +{ + public partial class UserTagControl : CompositeDrawable + { + private readonly BeatmapInfo beatmapInfo; + + public override bool HandlePositionalInput => true; + + private readonly Cached layout = new Cached(); + + private FillFlowContainer tagFlow = null!; + private LoadingLayer loadingLayer = null!; + + private BindableList displayedTags { get; } = new BindableList(); + private BindableList extraTags { get; } = new BindableList(); + + private Bindable allTags = null!; + private readonly Bindable apiBeatmap = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public UserTagControl(BeatmapInfo beatmapInfo) + { + this.beatmapInfo = beatmapInfo; + } + + [BackgroundDependencyLoader] + private void load(SessionStatics sessionStatics) + { + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(8), + Children = new Drawable[] + { + tagFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + LayoutDuration = 300, + LayoutEasing = Easing.OutQuint, + Spacing = new Vector2(4), + }, + new AddTagsButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + OnTagSelected = onExtraTagSelected, + AvailableTags = { BindTarget = extraTags }, + }, + }, + }, + loadingLayer = new LoadingLayer + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible } + }, + }; + + allTags = sessionStatics.GetBindable(Static.AllBeatmapTags); + + if (allTags.Value == null) + { + var listTagsRequest = new ListTagsRequest(); + listTagsRequest.Success += tags => allTags.Value = tags.Tags.ToArray(); + api.Queue(listTagsRequest); + } + + var getBeatmapSetRequest = new GetBeatmapSetRequest(beatmapInfo.BeatmapSet!.OnlineID); + getBeatmapSetRequest.Success += set => apiBeatmap.Value = set.Beatmaps.SingleOrDefault(b => b.MatchesOnlineID(beatmapInfo)); + api.Queue(getBeatmapSetRequest); + } + + private void onExtraTagSelected(UserTag tag) + { + loadingLayer.Show(); + extraTags.Remove(tag); + + var req = new AddBeatmapTagRequest(beatmapInfo.OnlineID, tag.Id); + req.Success += () => + { + tag.Voted.Value = true; + tag.VoteCount.Value += 1; + displayedTags.Add(tag); + loadingLayer.Hide(); + }; + req.Failure += _ => extraTags.Add(tag); + api.Queue(req); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + allTags.BindValueChanged(_ => updateTags()); + apiBeatmap.BindValueChanged(_ => updateTags()); + updateTags(); + + displayedTags.BindCollectionChanged(displayTags, true); + } + + private void updateTags() + { + if (allTags.Value == null || apiBeatmap.Value?.TopTags == null) + return; + + var allTagsById = allTags.Value.ToDictionary(t => t.Id); + var ownTagIds = apiBeatmap.Value.OwnTagIds?.ToHashSet() ?? new HashSet(); + + foreach (var topTag in apiBeatmap.Value.TopTags) + { + if (allTagsById.Remove(topTag.TagId, out var tag)) + { + displayedTags.Add(new UserTag(tag) + { + VoteCount = { Value = topTag.VoteCount }, + Voted = { Value = ownTagIds.Contains(tag.Id) } + }); + } + } + + extraTags.AddRange(allTagsById.Select(t => new UserTag(t.Value))); + + loadingLayer.Hide(); + } + + private void displayTags(object? sender, NotifyCollectionChangedEventArgs e) + { + var oldItems = tagFlow.ToArray(); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + { + var tag = (UserTag)e.NewItems[i]!; + var drawableTag = new DrawableUserTag(tag); + tagFlow.Insert(tagFlow.Count, drawableTag); + tag.VoteCount.BindValueChanged(voteCountChanged, true); + layout.Invalidate(); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + for (int i = 0; i < e.OldItems!.Count; i++) + { + var tag = (UserTag)e.OldItems[i]!; + tag.VoteCount.ValueChanged -= voteCountChanged; + tagFlow.Remove(oldItems[e.OldStartingIndex + i], true); + } + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + tagFlow.Clear(); + break; + } + } + } + + private void voteCountChanged(ValueChangedEvent _) + { + var tagsWithNoVotes = displayedTags.Where(t => t.VoteCount.Value == 0).ToArray(); + + foreach (var tag in tagsWithNoVotes) + { + displayedTags.Remove(tag); + extraTags.Add(tag); + } + + layout.Invalidate(); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid && !IsHovered) + { + var sortedTags = new Dictionary( + displayedTags.OrderByDescending(t => t.VoteCount.Value) + .ThenByDescending(t => t.Voted.Value) + .Select((tag, index) => new KeyValuePair(tag, index))); + + foreach (var drawableTag in tagFlow) + tagFlow.SetLayoutPosition(drawableTag, sortedTags[drawableTag.UserTag]); + + layout.Validate(); + } + } + + private partial class DrawableUserTag : OsuAnimatedButton + { + public readonly UserTag UserTag; + + private readonly Bindable voteCount = new Bindable(); + private readonly BindableBool voted = new BindableBool(); + private readonly Bindable confirmed = new BindableBool(); + + private Box mainBackground = null!; + private Box voteBackground = null!; + private OsuSpriteText tagNameText = null!; + private OsuSpriteText voteCountText = null!; + private LoadingSpinner spinner = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private APIRequest? requestInFlight; + + public DrawableUserTag(UserTag userTag) + { + UserTag = userTag; + voteCount.BindTo(userTag.VoteCount); + voted.BindTo(userTag.Voted); + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + CornerRadius = 8; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = colours.Lime1, + Radius = 5, + Type = EdgeEffectType.Glow, + }; + Content.AddRange(new Drawable[] + { + mainBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = 6, Right = 3, Vertical = 3, }, + Spacing = new Vector2(5), + Children = new Drawable[] + { + tagNameText = new OsuSpriteText + { + Text = UserTag.Name, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = 5, + Masking = true, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + voteBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + voteCountText = new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 6, Vertical = 3, }, + }, + spinner = new LoadingSpinner(withBox: true) + { + Alpha = 0, + Size = new Vector2(18), + } + } + } + } + } + }); + + TooltipText = UserTag.Description; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double transition_duration = 300; + + voteCount.BindValueChanged(_ => + { + voteCountText.Text = voteCount.Value.ToLocalisableString(); + confirmed.Value = voteCount.Value >= 10; + }, true); + voted.BindValueChanged(v => + { + if (v.NewValue) + { + voteBackground.FadeColour(colours.Lime3, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + } + else + { + voteBackground.FadeColour(colours.Gray2, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + } + }, true); + confirmed.BindValueChanged(c => + { + if (c.NewValue) + { + mainBackground.FadeColour(colours.Lime1, transition_duration, Easing.OutQuint); + tagNameText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0.5f, transition_duration, Easing.OutQuint); + } + else + { + mainBackground.FadeColour(colours.Gray4, transition_duration, Easing.OutQuint); + tagNameText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0f, transition_duration, Easing.OutQuint); + } + }, true); + FinishTransforms(true); + + Action = () => + { + if (requestInFlight != null) + return; + + spinner.Show(); + + APIRequest request; + + switch (voted.Value) + { + case true: + var removeReq = new RemoveBeatmapTagRequest(beatmap.Value.BeatmapInfo.OnlineID, UserTag.Id); + removeReq.Success += () => + { + voteCount.Value -= 1; + voted.Value = false; + }; + request = removeReq; + break; + + case false: + var addReq = new AddBeatmapTagRequest(beatmap.Value.BeatmapInfo.OnlineID, UserTag.Id); + addReq.Success += () => + { + voteCount.Value += 1; + voted.Value = true; + }; + request = addReq; + break; + } + + request.Success += () => + { + spinner.Hide(); + requestInFlight = null; + }; + request.Failure += _ => + { + spinner.Hide(); + requestInFlight = null; + }; + api.Queue(requestInFlight = request); + }; + } + } + + private partial class AddTagsButton : GrayButton, IHasPopover + { + public BindableList AvailableTags { get; } = new BindableList(); + + public Action? OnTagSelected { get; set; } + + public AddTagsButton() + : base(FontAwesome.Solid.Plus) + { + Size = new Vector2(30); + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AvailableTags.BindCollectionChanged((_, _) => Enabled.Value = AvailableTags.Count > 0, true); + } + + public Popover GetPopover() => new AddTagsPopover + { + AvailableTags = { BindTarget = AvailableTags }, + OnSelected = OnTagSelected, + }; + } + + private partial class AddTagsPopover : OsuPopover + { + private SearchTextBox searchBox = null!; + private SearchContainer searchContainer = null!; + + public BindableList AvailableTags { get; } = new BindableList(); + + public Action? OnSelected { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + Child = new OsuScrollContainer + { + Width = 250, + Height = 250, + ScrollbarOverlapsContent = false, + Children = new Drawable[] + { + searchBox = new SearchTextBox + { + HoldFocus = true, + RelativeSizeAxes = Axes.X, + }, + searchContainer = new SearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Right = 5, Top = 50, }, + Spacing = new Vector2(10), + ChildrenEnumerable = AvailableTags.Select(tag => new DrawableAddableTag(tag) + { + Action = () => select(tag) + }) + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + searchBox.Current.BindValueChanged(_ => searchContainer.SearchTerm = searchBox.Current.Value, true); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (base.OnPressed(e)) + return true; + + if (e.Repeat) + return false; + + if (State.Value == Visibility.Hidden) + return false; + + if (e.Action == GlobalAction.Select) + { + attemptSelect(); + return true; + } + + return false; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.Enter) + { + attemptSelect(); + return true; + } + + return base.OnKeyDown(e); + } + + private void attemptSelect() + { + var visibleItems = searchContainer.OfType().Where(d => d.IsPresent).ToArray(); + + if (visibleItems.Length == 1) + select(visibleItems.Single().Tag); + } + + private void select(UserTag tag) + { + OnSelected?.Invoke(tag); + this.HidePopover(); + } + + private partial class DrawableAddableTag : OsuAnimatedButton, IFilterable + { + public readonly UserTag Tag; + + public DrawableAddableTag(UserTag tag) + { + Tag = tag; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Anchor = Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider? colourProvider) + { + Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background3 ?? colours.GreySeaFoamDark, + Depth = float.MaxValue, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Padding = new MarginPadding(5), + Children = new Drawable[] + { + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.Bold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.Name, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.Description, + } + } + } + }); + } + + public IEnumerable FilterTerms => [Tag.Name, Tag.Description]; + + public bool MatchingFilter { set => Alpha = value ? 1 : 0; } + public bool FilteringActive { set { } } + } + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index fc7c7989e2..a807fc6a34 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -14,6 +14,7 @@ using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -112,27 +113,13 @@ namespace osu.Game.Screens.Select [Resolved] private RealmAccess realm { get; set; } = null!; - [Resolved] - private DetachedBeatmapStore? detachedBeatmapStore { get; set; } - private IBindableList? detachedBeatmapSets; private readonly NoResultsPlaceholder noResultsPlaceholder; private IEnumerable beatmapSets => root.Items.OfType(); - internal IEnumerable BeatmapSets - { - get => beatmapSets.Select(g => g.BeatmapSet); - set - { - if (LoadState != LoadState.NotLoaded) - throw new InvalidOperationException("If not using a realm source, beatmap sets must be set before load."); - - detachedBeatmapSets = new BindableList(value); - Schedule(loadNewRoot); - } - } + internal IEnumerable BeatmapSets => beatmapSets.Select(g => g.BeatmapSet); private void loadNewRoot() { @@ -198,8 +185,6 @@ namespace osu.Game.Screens.Select private readonly Cached itemsCache = new Cached(); private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None; - public Bindable RightClickScrollingEnabled = new Bindable(); - public Bindable RandomAlgorithm = new Bindable(); private readonly List previouslyVisitedRandomSets = new List(); private readonly List randomSelectedBeatmaps = new List(); @@ -234,25 +219,16 @@ namespace osu.Game.Screens.Select } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, AudioManager audio, CancellationToken? cancellationToken) + private void load(OsuConfigManager config, AudioManager audio, BeatmapStore beatmaps, CancellationToken? cancellationToken) { spinSample = audio.Samples.Get("SongSelect/random-spin"); randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); - config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.BindValueChanged(enabled => Scroll.RightMouseScrollbar = enabled.NewValue, true); - - if (detachedBeatmapStore != null && detachedBeatmapSets == null) - { - // This is performing an unnecessary second lookup on realm (in addition to the subscription), but for performance reasons - // we require it to be separate: the subscription's initial callback (with `ChangeSet` of `null`) will run on the update - // thread. If we attempt to detach beatmaps in this callback the game will fall over (it takes time). - detachedBeatmapSets = detachedBeatmapStore.GetDetachedBeatmaps(cancellationToken); - detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); - loadNewRoot(); - } + detachedBeatmapSets = beatmaps.GetBeatmapSets(cancellationToken); + detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); + loadNewRoot(); } private readonly HashSet setsRequiringUpdate = new HashSet(); @@ -631,12 +607,12 @@ namespace osu.Game.Screens.Select /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom; + private float visibleBottomBound => (float)(Scroll.Current + DrawHeight + BleedBottom); /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => Scroll.Current - BleedTop; + private float visibleUpperBound => (float)(Scroll.Current - BleedTop); public void FlushPendingFilterOperations() { @@ -1026,7 +1002,7 @@ namespace osu.Game.Screens.Select // we take the difference in scroll height and apply to all visible panels. // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer // to enter clamp-special-case mode where it animates completely differently to normal. - float scrollChange = scrollTarget.Value - Scroll.Current; + float scrollChange = (float)(scrollTarget.Value - Scroll.Current); Scroll.ScrollTo(scrollTarget.Value, false); foreach (var i in Scroll) i.Y += scrollChange; @@ -1181,10 +1157,8 @@ namespace osu.Game.Screens.Select } } - public partial class CarouselScrollContainer : UserTrackingScrollContainer + public partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler { - private bool rightMouseScrollBlocked; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public CarouselScrollContainer() @@ -1196,31 +1170,76 @@ namespace osu.Game.Screens.Select Masking = false; } + #region Absolute scrolling + + private bool absoluteScrolling; + + protected override bool IsDragging => base.IsDragging || absoluteScrolling; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + beginAbsoluteScrolling(e); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + endAbsoluteScrolling(); + break; + } + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button == MouseButton.Right) { - // we need to block right click absolute scrolling when hovering a carousel item so context menus can display. - // this can be reconsidered when we have an alternative to right click scrolling. - if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) - { - rightMouseScrollBlocked = true; + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) return false; - } + + beginAbsoluteScrolling(e); } - rightMouseScrollBlocked = false; return base.OnMouseDown(e); } - protected override bool OnDragStart(DragStartEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (rightMouseScrollBlocked) - return false; - - return base.OnDragStart(e); + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); } + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (absoluteScrolling) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + return true; + } + + return base.OnMouseMove(e); + } + + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + absoluteScrolling = true; + } + + private void endAbsoluteScrolling() => absoluteScrolling = false; + + #endregion + protected override ScrollbarContainer CreateScrollbar(Direction direction) { return new PaddedScrollbar(); @@ -1237,12 +1256,12 @@ namespace osu.Game.Screens.Select private const float top_padding = 10; private const float bottom_padding = 70; - protected override float ToScrollbarPosition(float scrollPosition) + protected override float ToScrollbarPosition(double scrollPosition) { if (Precision.AlmostEquals(0, ScrollableExtent)) return 0; - return top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent); + return (float)(top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent)); } protected override float FromScrollbarPosition(float scrollbarPosition) @@ -1250,7 +1269,7 @@ namespace osu.Game.Screens.Select if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) return 0; - return ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding))); + return (float)(ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding)))); } } } diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 3b0fdc3e47..fd1c944689 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -401,7 +401,6 @@ namespace osu.Game.Screens.Select if (beatmap == null || bpmLabelContainer == null) return; - // this doesn't consider mods which apply variable rates, yet. double rate = ModUtils.CalculateRateWithMods(mods.Value); int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index c007fa29ed..dc77b0101e 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -90,6 +90,12 @@ namespace osu.Game.Screens.Select.Carousel if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo, criteria); + if (match && criteria.HasOnlineID == true) + match &= BeatmapInfo.OnlineID >= 0; + + if (match && criteria.BeatmapSetId != null) + match &= criteria.BeatmapSetId == BeatmapInfo.BeatmapSet?.OnlineID; + return match; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 62d694976f..c0fb5fa397 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -10,8 +10,31 @@ namespace osu.Game.Screens.Select.Carousel /// /// A group which ensures only one item is selected. /// - public class CarouselGroup : CarouselItem + public abstract class CarouselGroup : CarouselItem { + protected CarouselGroup(List? items = null) + { + if (items != null) this.items = items; + + State.ValueChanged += state => + { + switch (state.NewValue) + { + case CarouselItemState.Collapsed: + case CarouselItemState.NotSelected: + this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); + break; + + case CarouselItemState.Selected: + this.items.ForEach(c => + { + if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; + }); + break; + } + }; + } + public override DrawableCarouselItem? CreateDrawableRepresentation() => null; public SlimReadOnlyListWrapper Items => items.AsSlimReadOnly(); @@ -67,29 +90,6 @@ namespace osu.Game.Screens.Select.Carousel TotalItemsNotFiltered++; } - public CarouselGroup(List? items = null) - { - if (items != null) this.items = items; - - State.ValueChanged += state => - { - switch (state.NewValue) - { - case CarouselItemState.Collapsed: - case CarouselItemState.NotSelected: - this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); - break; - - case CarouselItemState.Selected: - this.items.ForEach(c => - { - if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; - }); - break; - } - }; - } - public override void Filter(FilterCriteria criteria) { base.Filter(criteria); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index cf4ba5924f..8cc1ea258a 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -10,9 +10,9 @@ namespace osu.Game.Screens.Select.Carousel /// /// A group which ensures at least one item is selected (if the group itself is selected). /// - public class CarouselGroupEagerSelect : CarouselGroup + public abstract class CarouselGroupEagerSelect : CarouselGroup { - public CarouselGroupEagerSelect() + protected CarouselGroupEagerSelect() { State.ValueChanged += state => { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 75c13c1be6..b99f046f4b 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -88,6 +88,9 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private OsuGame? game { get; set; } + [Resolved] + private BeatmapManager? manager { get; set; } + private IBindable starDifficultyBindable = null!; private CancellationTokenSource? starDifficultyCancellationSource; @@ -98,7 +101,7 @@ namespace osu.Game.Screens.Select.Carousel } [BackgroundDependencyLoader] - private void load(BeatmapManager? manager, SongSelect? songSelect) + private void load(SongSelect? songSelect) { Header.Height = height; @@ -298,7 +301,10 @@ namespace osu.Game.Screens.Select.Carousel items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (beatmapInfo.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); + + if (manager != null) + items.Add(new OsuMenuItem("Mark as played", MenuItemType.Standard, () => manager.MarkPlayed(beatmapInfo))); if (hideRequested != null) items.Add(new OsuMenuItem(WebCommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 996d9ea0ab..c410cb7d69 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -301,7 +301,7 @@ namespace osu.Game.Screens.Select.Carousel items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); if (dialogOverlay != null) items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index b221296ba8..488f63accb 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Select [CanBeNull] private FilterCriteria currentCriteria; - public FilterCriteria CreateCriteria() + public virtual FilterCriteria CreateCriteria() { string query = searchTextBox.Text; diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 76c0f769f0..15cb3c5104 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -56,6 +56,9 @@ namespace osu.Game.Screens.Select public RulesetInfo? Ruleset; public IReadOnlyList? Mods; public bool AllowConvertedBeatmaps; + public int? BeatmapSetId; + + public bool? HasOnlineID; private string searchText = string.Empty; diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 128e750dca..dafa0b0c1c 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -25,6 +25,11 @@ namespace osu.Game.Screens.Select protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); + /// + /// Used to show an initial animation hinting at the enabled state. + /// + protected virtual bool IsActive => false; + public LocalisableString Text { get => SpriteText?.Text ?? default; @@ -124,6 +129,18 @@ namespace osu.Game.Screens.Select { base.LoadComplete(); Enabled.BindValueChanged(_ => updateDisplay(), true); + + if (IsActive) + { + box.ClearTransforms(); + + using (box.BeginDelayedSequence(200)) + { + box.FadeIn(200) + .Then() + .FadeOut(1500, Easing.OutQuint); + } + } } public Action Hovered; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 58c14b15b9..46705aaa28 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -138,12 +138,18 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - if (!api.LocalUser.Value.IsSupporter && (Scope != BeatmapLeaderboardScope.Global || filterMods)) + if (Scope.RequiresSupporter(filterMods) && !api.LocalUser.Value.IsSupporter) { SetErrorState(LeaderboardState.NotSupporter); return null; } + if (Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null) + { + SetErrorState(LeaderboardState.NoTeam); + return null; + } + IReadOnlyList? requestMods = null; if (filterMods && !mods.Value.Any()) @@ -169,12 +175,12 @@ namespace osu.Game.Screens.Select.Leaderboards return scoreRetrievalRequest = newRequest; } - protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope) + protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) }; - protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false) + protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) }; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index e2e3404877..a3687d9586 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -20,5 +20,8 @@ namespace osu.Game.Screens.Select.Leaderboards [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardFriend))] Friend, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardTeam))] + Team, } } diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index deb1100dfc..5b62d5e8d7 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -83,6 +83,7 @@ namespace osu.Game.Screens.Select new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team), }).ToArray(); private BeatmapDetailAreaTabItem getTabItemFromTabType(TabType type) @@ -104,6 +105,9 @@ namespace osu.Game.Screens.Select case TabType.Friends: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); + case TabType.Team: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team); + default: throw new ArgumentOutOfRangeException(nameof(type)); } @@ -131,6 +135,9 @@ namespace osu.Game.Screens.Select case BeatmapLeaderboardScope.Friend: return TabType.Friends; + case BeatmapLeaderboardScope.Team: + return TabType.Team; + default: throw new ArgumentOutOfRangeException(nameof(item)); } @@ -146,7 +153,8 @@ namespace osu.Game.Screens.Select Local, Country, Global, - Friends + Friends, + Team } } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 9f7a2c02ff..1496eb96f9 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -31,6 +31,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Backgrounds; @@ -82,6 +83,11 @@ namespace osu.Game.Screens.Select /// protected Container FooterPanels { get; private set; } = null!; + /// + /// The that opens the mod select dialog. + /// + protected FooterButton ModsFooterButton { get; private set; } = null!; + /// /// Whether entering editor mode should be allowed. /// @@ -169,10 +175,12 @@ namespace osu.Game.Screens.Select AddRangeInternal(new Drawable[] { + new GlobalScrollAdjustsVolume(), new VerticalMaskingContainer { Children = new Drawable[] { + new GlobalScrollAdjustsVolume(), new GridContainer // used for max width implementation { RelativeSizeAxes = Axes.Both, @@ -211,11 +219,11 @@ namespace osu.Game.Screens.Select }, } }, - FilterControl = new FilterControl + FilterControl = CreateFilterControl().With(d => { - RelativeSizeAxes = Axes.X, - Height = FilterControl.HEIGHT, - }, + d.RelativeSizeAxes = Axes.X; + d.Height = FilterControl.HEIGHT; + }), new GridContainer // used for max width implementation { RelativeSizeAxes = Axes.Both, @@ -375,7 +383,7 @@ namespace osu.Game.Screens.Select BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => DeleteBeatmap(Beatmap.Value.BeatmapSetInfo)); - BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null); + BeatmapOptions.AddButton(@"Mark", @"as played", FontAwesome.Regular.TimesCircle, colours.Purple, () => beatmaps.MarkPlayed(Beatmap.Value.BeatmapInfo)); BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => ClearScores(Beatmap.Value.BeatmapInfo)); } @@ -384,6 +392,8 @@ namespace osu.Game.Screens.Select SampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection"); } + protected virtual FilterControl CreateFilterControl() => new FilterControl(); + protected override void LoadComplete() { base.LoadComplete(); @@ -405,9 +415,9 @@ namespace osu.Game.Screens.Select /// Creates the buttons to be displayed in the footer. /// /// A set of and an optional which the button opens when pressed. - protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] + protected virtual IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] { - (new FooterButtonMods { Current = Mods }, ModSelect), + (ModsFooterButton = new FooterButtonMods { Current = Mods }, ModSelect), (new FooterButtonRandom { NextRandom = () => Carousel.SelectNextRandom(), @@ -416,7 +426,10 @@ namespace osu.Game.Screens.Select (beatmapOptionsButton = new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay + { + ShowPresets = true, + }; private DependencyContainer dependencies = null!; @@ -1142,10 +1155,5 @@ namespace osu.Game.Screens.Select return base.OnHover(e); } } - - internal partial class SoloModSelectOverlay : UserModSelectOverlay - { - protected override bool ShowPresets => true; - } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs new file mode 100644 index 0000000000..1c1f6fa7fb --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -0,0 +1,336 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.SelectV2 +{ + [Cached] + public partial class BeatmapCarousel : Carousel + { + public Action? RequestPresentBeatmap { private get; init; } + + public const float SPACING = 5f; + + private IBindableList detachedBeatmaps = null!; + + private readonly LoadingLayer loading; + + private readonly BeatmapCarouselFilterGrouping grouping; + + protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) + { + if (top.Model is BeatmapInfo || bottom.Model is BeatmapInfo) + // Beatmap difficulty panels do not overlap with themselves or any other panel. + return SPACING; + + return -SPACING; + } + + public BeatmapCarousel() + { + DebounceDelay = 100; + DistanceOffscreenToPreload = 100; + + Filters = new ICarouselFilter[] + { + new BeatmapCarouselFilterSorting(() => Criteria), + grouping = new BeatmapCarouselFilterGrouping(() => Criteria), + }; + + AddInternal(loading = new LoadingLayer(dimBackground: true)); + } + + [BackgroundDependencyLoader] + private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken) + { + setupPools(); + setupBeatmaps(beatmapStore, cancellationToken); + } + + #region Beatmap source hookup + + private void setupBeatmaps(BeatmapStore beatmapStore, CancellationToken? cancellationToken) + { + detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); + } + + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) + { + // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. + // right now we are managing this locally which is a bit of added overhead. + IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); + IEnumerable? beatmapSetInfos = changed.OldItems?.Cast(); + + switch (changed.Action) + { + case NotifyCollectionChangedAction.Add: + Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps)); + break; + + case NotifyCollectionChangedAction.Remove: + + foreach (var set in beatmapSetInfos!) + { + foreach (var beatmap in set.Beatmaps) + Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); + } + + break; + + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Replace: + throw new NotImplementedException(); + + case NotifyCollectionChangedAction.Reset: + Items.Clear(); + break; + } + } + + #endregion + + #region Selection handling + + private GroupDefinition? lastSelectedGroup; + private BeatmapInfo? lastSelectedBeatmap; + + protected override void HandleItemActivated(CarouselItem item) + { + switch (item.Model) + { + case GroupDefinition group: + // Special case – collapsing an open group. + if (lastSelectedGroup == group) + { + setExpansionStateOfGroup(lastSelectedGroup, false); + lastSelectedGroup = null; + return; + } + + setExpandedGroup(group); + return; + + case BeatmapSetInfo setInfo: + // Selecting a set isn't valid – let's re-select the first difficulty. + CurrentSelection = setInfo.Beatmaps.First(); + return; + + case BeatmapInfo beatmapInfo: + if (ReferenceEquals(CurrentSelection, beatmapInfo)) + { + RequestPresentBeatmap?.Invoke(beatmapInfo); + return; + } + + CurrentSelection = beatmapInfo; + return; + } + } + + protected override void HandleItemSelected(object? model) + { + base.HandleItemSelected(model); + + switch (model) + { + case BeatmapSetInfo: + case GroupDefinition: + throw new InvalidOperationException("Groups should never become selected"); + + case BeatmapInfo beatmapInfo: + // Find any containing group. There should never be too many groups so iterating is efficient enough. + GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key; + + if (containingGroup != null) + setExpandedGroup(containingGroup); + setExpandedSet(beatmapInfo); + break; + } + } + + protected override bool CheckValidForGroupSelection(CarouselItem item) + { + switch (item.Model) + { + case BeatmapSetInfo: + return true; + + case BeatmapInfo: + return !grouping.BeatmapSetsGroupedTogether; + + case GroupDefinition: + return false; + + default: + throw new ArgumentException($"Unsupported model type {item.Model}"); + } + } + + private void setExpandedGroup(GroupDefinition group) + { + if (lastSelectedGroup != null) + setExpansionStateOfGroup(lastSelectedGroup, false); + lastSelectedGroup = group; + setExpansionStateOfGroup(group, true); + } + + private void setExpansionStateOfGroup(GroupDefinition group, bool expanded) + { + if (grouping.GroupItems.TryGetValue(group, out var items)) + { + if (expanded) + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = true; + break; + + case BeatmapSetInfo set: + // Case where there are set headers, header should be visible + // and items should use the set's expanded state. + i.IsVisible = true; + setExpansionStateOfSetItems(set, i.IsExpanded); + break; + + default: + // Case where there are no set headers, all items should be visible. + if (!grouping.BeatmapSetsGroupedTogether) + i.IsVisible = true; + break; + } + } + } + else + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = false; + break; + + default: + i.IsVisible = false; + break; + } + } + } + } + } + + private void setExpandedSet(BeatmapInfo beatmapInfo) + { + if (lastSelectedBeatmap != null) + setExpansionStateOfSetItems(lastSelectedBeatmap.BeatmapSet!, false); + lastSelectedBeatmap = beatmapInfo; + setExpansionStateOfSetItems(beatmapInfo.BeatmapSet!, true); + } + + private void setExpansionStateOfSetItems(BeatmapSetInfo set, bool expanded) + { + if (grouping.SetItems.TryGetValue(set, out var items)) + { + foreach (var i in items) + { + if (i.Model is BeatmapSetInfo) + i.IsExpanded = expanded; + else + i.IsVisible = expanded; + } + } + } + + #endregion + + #region Animation + + /// + /// Moves non-selected beatmaps to the right, hiding off-screen. + /// + public bool VisuallyFocusSelected { get; set; } + + private float selectionFocusOffset; + + protected override void Update() + { + base.Update(); + + selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed); + } + + protected override float GetPanelXOffset(Drawable panel) + { + return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset); + } + + #endregion + + #region Filtering + + public FilterCriteria Criteria { get; private set; } = new FilterCriteria(); + + public void Filter(FilterCriteria criteria) + { + Criteria = criteria; + loading.Show(); + FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide())); + } + + #endregion + + #region Drawable pooling + + private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); + private readonly DrawablePool setPanelPool = new DrawablePool(100); + private readonly DrawablePool groupPanelPool = new DrawablePool(100); + + private void setupPools() + { + AddInternal(groupPanelPool); + AddInternal(beatmapPanelPool); + AddInternal(setPanelPool); + } + + protected override Drawable GetDrawableForDisplay(CarouselItem item) + { + switch (item.Model) + { + case GroupDefinition: + return groupPanelPool.Get(); + + case BeatmapInfo: + // TODO: if beatmap is a group selection target, it needs to be a different drawable + // with more information attached. + return beatmapPanelPool.Get(); + + case BeatmapSetInfo: + return setPanelPool.Get(); + } + + throw new InvalidOperationException(); + } + + #endregion + } + + public record GroupDefinition(object Data, string Title); +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs new file mode 100644 index 0000000000..8f9d5cc31b --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterGrouping : ICarouselFilter + { + public bool BeatmapSetsGroupedTogether { get; private set; } + + /// + /// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection. + /// + public IDictionary> SetItems => setItems; + + /// + /// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection. + /// + public IDictionary> GroupItems => groupItems; + + private readonly Dictionary> setItems = new Dictionary>(); + private readonly Dictionary> groupItems = new Dictionary>(); + + private readonly Func getCriteria; + + public BeatmapCarouselFilterGrouping(Func getCriteria) + { + this.getCriteria = getCriteria; + } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + setItems.Clear(); + groupItems.Clear(); + + var criteria = getCriteria(); + var newItems = new List(); + + BeatmapInfo? lastBeatmap = null; + GroupDefinition? lastGroup = null; + + HashSet? currentGroupItems = null; + HashSet? currentSetItems = null; + + BeatmapSetsGroupedTogether = criteria.Group != GroupMode.Difficulty; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var beatmap = (BeatmapInfo)item.Model; + + if (createGroupIfRequired(criteria, beatmap, lastGroup) is GroupDefinition newGroup) + { + // When reaching a new group, ensure we reset any beatmap set tracking. + currentSetItems = null; + lastBeatmap = null; + + groupItems[newGroup] = currentGroupItems = new HashSet(); + lastGroup = newGroup; + + addItem(new CarouselItem(newGroup) + { + DrawHeight = PanelGroup.HEIGHT, + DepthLayer = -2, + }); + } + + if (BeatmapSetsGroupedTogether) + { + bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID; + + if (newBeatmapSet) + { + setItems[beatmap.BeatmapSet!] = currentSetItems = new HashSet(); + + addItem(new CarouselItem(beatmap.BeatmapSet!) + { + DrawHeight = PanelBeatmapSet.HEIGHT, + DepthLayer = -1 + }); + } + } + + addItem(item); + lastBeatmap = beatmap; + + void addItem(CarouselItem i) + { + newItems.Add(i); + + currentGroupItems?.Add(i); + currentSetItems?.Add(i); + + i.IsVisible = i.Model is GroupDefinition || (lastGroup == null && (i.Model is BeatmapSetInfo || currentSetItems == null)); + } + } + + return newItems; + }, cancellationToken).ConfigureAwait(false); + } + + private GroupDefinition? createGroupIfRequired(FilterCriteria criteria, BeatmapInfo beatmap, GroupDefinition? lastGroup) + { + switch (criteria.Group) + { + case GroupMode.Artist: + char groupChar = lastGroup?.Data as char? ?? (char)0; + char beatmapFirstChar = char.ToUpperInvariant(beatmap.Metadata.Artist[0]); + + if (beatmapFirstChar > groupChar) + return new GroupDefinition(beatmapFirstChar, $"{beatmapFirstChar}"); + + break; + + case GroupMode.Difficulty: + int starGroup = lastGroup?.Data as int? ?? -1; + + if (beatmap.StarRating > starGroup) + { + starGroup = (int)Math.Floor(beatmap.StarRating); + return new GroupDefinition(starGroup + 1, $"{starGroup} - {starGroup + 1} *"); + } + + break; + } + + return null; + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs new file mode 100644 index 0000000000..3cdbbb4fed --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs @@ -0,0 +1,60 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterSorting : ICarouselFilter + { + private readonly Func getCriteria; + + public BeatmapCarouselFilterSorting(Func getCriteria) + { + this.getCriteria = getCriteria; + } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + var criteria = getCriteria(); + + return items.Order(Comparer.Create((a, b) => + { + int comparison; + + var ab = (BeatmapInfo)a.Model; + var bb = (BeatmapInfo)b.Model; + + switch (criteria.Sort) + { + case SortMode.Artist: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.Metadata.Artist, bb.Metadata.Artist); + if (comparison == 0) + goto case SortMode.Title; + break; + + case SortMode.Difficulty: + comparison = ab.StarRating.CompareTo(bb.StarRating); + break; + + case SortMode.Title: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.Metadata.Title, bb.Metadata.Title); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + return comparison; + })); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs b/osu.Game/Screens/SelectV2/BeatmapInfoWedgeV2.cs similarity index 99% rename from osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs rename to osu.Game/Screens/SelectV2/BeatmapInfoWedgeV2.cs index 3c76ae1f08..b294896c77 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs +++ b/osu.Game/Screens/SelectV2/BeatmapInfoWedgeV2.cs @@ -3,23 +3,24 @@ using System; using System.Threading; -using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osuTK; -namespace osu.Game.Screens.Select +namespace osu.Game.Screens.SelectV2 { public partial class BeatmapInfoWedgeV2 : VisibilityContainer { diff --git a/osu.Game/Screens/SelectV2/BeatmapSetPanelBackground.cs b/osu.Game/Screens/SelectV2/BeatmapSetPanelBackground.cs new file mode 100644 index 0000000000..798acf62ee --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapSetPanelBackground.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapSetPanelBackground : ModelBackedDrawable + { + protected override double TransformDuration => 400; + + public WorkingBeatmap? Beatmap + { + get => Model; + set => Model = value; + } + + protected override Drawable CreateDrawable(WorkingBeatmap? model) => new BackgroundSprite(model); + + private partial class BackgroundSprite : CompositeDrawable + { + private readonly WorkingBeatmap? working; + + public BackgroundSprite(WorkingBeatmap? working) + { + this.working = working; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + var texture = working?.GetPanelBackground(); + + if (texture != null) + { + InternalChildren = new Drawable[] + { + new Sprite + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + Texture = texture, + }, + new FillFlowContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle + Shear = new Vector2(0.8f, 0), + Alpha = 0.5f, + Children = new[] + { + // The left half with no gradient applied + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Width = 0.4f, + }, + // Piecewise-linear gradient with 3 segments to make it appear smoother + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), + Width = 0.05f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), + Width = 0.2f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), + Width = 0.05f, + }, + } + }, + }; + } + else + { + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs new file mode 100644 index 0000000000..5339b5358b --- /dev/null +++ b/osu.Game/Screens/SelectV2/Carousel.cs @@ -0,0 +1,856 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Framework.Logging; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// A highly efficient vertical list display that is used primarily for the song select screen, + /// but flexible enough to be used for other use cases. + /// + public abstract partial class Carousel : CompositeDrawable, IKeyBindingHandler + where T : notnull + { + #region Properties and methods for external usage + + /// + /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedTop { get; set; } = 0; + + /// + /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedBottom { get; set; } = 0; + + /// + /// The number of pixels outside the carousel's vertical bounds to manifest drawables. + /// This allows preloading content before it scrolls into view. + /// + public float DistanceOffscreenToPreload { get; set; } + + /// + /// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter. + /// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations. + /// + public int DebounceDelay { get; set; } + + /// + /// Whether an asynchronous filter / group operation is currently underway. + /// + public bool IsFiltering => !filterTask.IsCompleted; + + /// + /// The number of displayable items currently being tracked (before filtering). + /// + public int ItemsTracked => Items.Count; + + /// + /// The number of carousel items currently in rotation for display. + /// + public int DisplayableItems => carouselItems?.Count ?? 0; + + /// + /// The number of items currently actualised into drawables. + /// + public int VisibleItems => Scroll.Panels.Count; + + /// + /// The currently selected model. Generally of type T. + /// + /// + /// A carousel may create panels for non-T types. + /// To keep things simple, we therefore avoid generic constraints on the current selection. + /// + /// The selection is never reset due to not existing. It can be set to anything. + /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches. + /// + public object? CurrentSelection + { + get => currentSelection.Model; + set + { + if (currentSelection.Model != value) + { + HandleItemSelected(value); + + if (currentSelection.Model != null) + HandleItemDeselected(currentSelection.Model); + + currentKeyboardSelection = new Selection(value); + currentSelection = currentKeyboardSelection; + selectionValid.Invalidate(); + } + else if (currentKeyboardSelection.Model != value) + { + // Even if the current selection matches, let's ensure the keyboard selection is reset + // to the newly selected object. This matches user expectations (for now). + currentKeyboardSelection = currentSelection; + selectionValid.Invalidate(); + } + } + } + + /// + /// Activate the specified item. + /// + /// + public void Activate(CarouselItem item) + { + // Regardless of how the item handles activation, update keyboard selection to the activated panel. + // In other words, when a panel is clicked, keyboard selection should default to matching the clicked + // item. + setKeyboardSelection(item.Model); + + (GetMaterialisedDrawableForItem(item) as ICarouselPanel)?.Activated(); + HandleItemActivated(item); + + selectionValid.Invalidate(); + } + + /// + /// Returns the vertical spacing between two given carousel items. Negative value can be used to create an overlapping effect. + /// + protected virtual float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) => 0f; + + #endregion + + #region Properties and methods concerning implementations + + /// + /// A collection of filters which should be run each time a is executed. + /// + /// + /// Implementations should add all required filters as part of their initialisation. + /// + /// Importantly, each filter is sequentially run in the order provided. + /// Each filter receives the output of the previous filter. + /// + /// A filter may add, mutate or remove items. + /// + public IEnumerable Filters { get; init; } = Enumerable.Empty(); + + /// + /// All items which are to be considered for display in this carousel. + /// Mutating this list will automatically queue a . + /// + /// + /// Note that an may add new items which are displayed but not tracked in this list. + /// + protected readonly BindableList Items = new BindableList(); + + /// + /// Queue an asynchronous filter operation. + /// + protected virtual Task FilterAsync() => filterTask = performFilter(); + + /// + /// Create a drawable for the given carousel item so it can be displayed. + /// + /// + /// For efficiency, it is recommended the drawables are retrieved from a . + /// + /// The item which should be represented by the returned drawable. + /// The manifested drawable. + protected abstract Drawable GetDrawableForDisplay(CarouselItem item); + + /// + /// Given a , find a drawable representation if it is currently displayed in the carousel. + /// + /// + /// This will only return a drawable if it is "on-screen". + /// + /// The item to find a related drawable representation. + /// The drawable representation if it exists. + protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => + Scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); + + /// + /// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target. + /// + /// The candidate item. + /// Whether the provided item is a valid group target. If false, more panels will be checked in the user's requested direction until a valid target is found. + protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true; + + /// + /// Called after an item becomes the . + /// Should be used to handle any group expansion, item visibility changes, etc. + /// + protected virtual void HandleItemSelected(object? model) { } + + /// + /// Called when the changes to a new selection. + /// Should be used to handle any group expansion, item visibility changes, etc. + /// + protected virtual void HandleItemDeselected(object? model) { } + + /// + /// Called when an item is activated via user input (keyboard traversal or a mouse click). + /// + /// + /// An activated item should decide to perform an action, such as: + /// - Change its expanded state (and show / hide children items). + /// - Set the item to the . + /// - Start gameplay on a beatmap difficulty if already selected. + /// + /// The carousel item which was activated. + protected virtual void HandleItemActivated(CarouselItem item) { } + + #endregion + + #region Initialisation + + protected readonly CarouselScrollContainer Scroll; + + protected Carousel() + { + InternalChild = Scroll = new CarouselScrollContainer + { + RelativeSizeAxes = Axes.Both, + }; + + Items.BindCollectionChanged((_, _) => FilterAsync()); + } + + #endregion + + #region Filtering and display preparation + + private List? carouselItems; + + private Task filterTask = Task.CompletedTask; + private CancellationTokenSource cancellationSource = new CancellationTokenSource(); + + private async Task performFilter() + { + Stopwatch stopwatch = Stopwatch.StartNew(); + var cts = new CancellationTokenSource(); + + var previousCancellationSource = Interlocked.Exchange(ref cancellationSource, cts); + await previousCancellationSource.CancelAsync().ConfigureAwait(false); + + if (DebounceDelay > 0) + { + log($"Filter operation queued, waiting for {DebounceDelay} ms debounce"); + await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(false); + } + + // Copy must be performed on update thread for now (see ConfigureAwait above). + // Could potentially be optimised in the future if it becomes an issue. + IEnumerable items = new List(Items.Select(m => new CarouselItem(m))); + + await Task.Run(async () => + { + try + { + foreach (var filter in Filters) + { + log($"Performing {filter.GetType().ReadableName()}"); + items = await filter.Run(items, cts.Token).ConfigureAwait(false); + } + + log("Updating Y positions"); + updateYPositions(items, visibleHalfHeight); + } + catch (OperationCanceledException) + { + log("Cancelled due to newer request arriving"); + } + }, cts.Token).ConfigureAwait(false); + + if (cts.Token.IsCancellationRequested) + return; + + Schedule(() => + { + log("Items ready for display"); + carouselItems = items.ToList(); + displayedRange = null; + + // Need to call this to ensure correct post-selection logic is handled on the new items list. + HandleItemSelected(currentSelection.Model); + + refreshAfterSelection(); + }); + + void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}"); + } + + private void updateYPositions(IEnumerable carouselItems, float offset) + { + CarouselItem? previousVisible = null; + + foreach (var item in carouselItems) + updateItemYPosition(item, ref previousVisible, ref offset); + } + + private void updateItemYPosition(CarouselItem item, ref CarouselItem? previousVisible, ref float offset) + { + float spacing = previousVisible == null || !item.IsVisible ? 0 : GetSpacingBetweenPanels(previousVisible, item); + + offset += spacing; + item.CarouselYPosition = offset; + + if (item.IsVisible) + { + offset += item.DrawHeight; + previousVisible = item; + } + } + + #endregion + + #region Input handling + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Select: + if (currentKeyboardSelection.CarouselItem != null) + Activate(currentKeyboardSelection.CarouselItem); + return true; + + case GlobalAction.SelectNext: + traverseKeyboardSelection(1); + return true; + + case GlobalAction.SelectPrevious: + traverseKeyboardSelection(-1); + return true; + + case GlobalAction.SelectNextGroup: + traverseGroupSelection(1); + return true; + + case GlobalAction.SelectPreviousGroup: + traverseGroupSelection(-1); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private void traverseKeyboardSelection(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) return; + + int originalIndex; + + if (currentKeyboardSelection.Index != null) + originalIndex = currentKeyboardSelection.Index.Value; + else if (direction > 0) + originalIndex = carouselItems.Count - 1; + else + originalIndex = 0; + + int newIndex = originalIndex; + + // Iterate over every item back to the current selection, finding the first valid item. + // The fail condition is when we reach the selection after a cyclic loop over every item. + do + { + newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + var newItem = carouselItems[newIndex]; + + if (newItem.IsVisible) + { + setKeyboardSelection(newItem.Model); + return; + } + } while (newIndex != originalIndex); + } + + /// + /// Select the next valid selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether selection was possible. + private void traverseGroupSelection(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) return; + + // If the user has a different keyboard selection and requests + // group selection, first transfer the keyboard selection to actual selection. + if (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) + { + Activate(currentKeyboardSelection.CarouselItem); + return; + } + + int originalIndex; + int newIndex; + + if (currentKeyboardSelection.Index == null) + { + // If there's no current selection, start from either end of the full list. + newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0; + } + else + { + newIndex = originalIndex = currentKeyboardSelection.Index.Value; + + // As a second special case, if we're group selecting backwards and the current selection isn't a group, + // make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early. + if (direction < 0) + { + while (newIndex > 0 && !CheckValidForGroupSelection(carouselItems[newIndex])) + newIndex--; + } + } + + // Iterate over every item back to the current selection, finding the first valid item. + // The fail condition is when we reach the selection after a cyclic loop over every item. + do + { + newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + var newItem = carouselItems[newIndex]; + + if (CheckValidForGroupSelection(newItem)) + { + HandleItemActivated(newItem); + return; + } + } while (newIndex != originalIndex); + } + + #endregion + + #region Selection handling + + private readonly Cached selectionValid = new Cached(); + + private Selection currentKeyboardSelection = new Selection(); + private Selection currentSelection = new Selection(); + + private void setKeyboardSelection(object? model) + { + currentKeyboardSelection = new Selection(model); + selectionValid.Invalidate(); + } + + /// + /// Call after a selection of items change to re-attach s to current s. + /// + private void refreshAfterSelection() + { + float yPos = visibleHalfHeight; + + // Invalidate display range as panel positions and visible status may have changed. + // Position transfer won't happen unless we invalidate this. + displayedRange = null; + + // The case where no items are available for display yet. + if (carouselItems == null) + { + currentKeyboardSelection = new Selection(); + currentSelection = new Selection(); + return; + } + + CarouselItem? lastVisible = null; + int count = carouselItems.Count; + + Selection prevKeyboard = currentKeyboardSelection; + + // We are performing two important operations here: + // - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions. + // - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use. + for (int i = 0; i < count; i++) + { + var item = carouselItems[i]; + + updateItemYPosition(item, ref lastVisible, ref yPos); + + if (ReferenceEquals(item.Model, currentKeyboardSelection.Model)) + currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + + if (ReferenceEquals(item.Model, currentSelection.Model)) + currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + } + + // If a keyboard selection is currently made, we want to keep the view stable around the selection. + // That means that we should offset the immediate scroll position by any change in Y position for the selection. + if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition) + Scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value)); + } + + private void scrollToSelection() + { + if (currentKeyboardSelection.CarouselItem != null) + Scroll.ScrollTo(currentKeyboardSelection.CarouselItem.CarouselYPosition - visibleHalfHeight); + } + + #endregion + + #region Display handling + + private DisplayRange? displayedRange; + + private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object()); + + /// + /// The position of the lower visible bound with respect to the current scroll position. + /// + private float visibleBottomBound => (float)(Scroll.Current + DrawHeight + BleedBottom); + + /// + /// The position of the upper visible bound with respect to the current scroll position. + /// + private float visibleUpperBound => (float)(Scroll.Current - BleedTop); + + /// + /// Half the height of the visible content. + /// + private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2; + + protected override void Update() + { + base.Update(); + + if (carouselItems == null) + return; + + if (!selectionValid.IsValid) + { + refreshAfterSelection(); + scrollToSelection(); + selectionValid.Validate(); + } + + var range = getDisplayRange(); + + if (range != displayedRange) + { + Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}"); + displayedRange = range; + + updateDisplayedRange(range); + } + + double selectedYPos = currentSelection.CarouselItem?.CarouselYPosition ?? 0; + + foreach (var panel in Scroll.Panels) + { + var c = (ICarouselPanel)panel; + + // panel in the process of expiring, ignore it. + if (c.Item == null) + continue; + + float normalisedDepth = (float)(Math.Abs(selectedYPos - c.DrawYPosition) / DrawHeight); + Scroll.Panels.ChangeChildDepth(panel, c.Item.DepthLayer + normalisedDepth); + + if (c.DrawYPosition != c.Item.CarouselYPosition) + c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed); + + panel.X = GetPanelXOffset(panel); + + c.Selected.Value = c.Item == currentSelection?.CarouselItem; + c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; + c.Expanded.Value = c.Item.IsExpanded; + } + } + + protected virtual float GetPanelXOffset(Drawable panel) + { + Vector2 posInScroll = Scroll.ToLocalSpace(panel.ScreenSpaceDrawQuad.Centre); + float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight); + + return offsetX(dist, visibleHalfHeight); + } + + /// + /// Computes the x-offset of currently visible items. Makes the carousel appear round. + /// + /// + /// Vertical distance from the center of the carousel container + /// ranging from -1 to 1. + /// + /// Half the height of the carousel container. + private static float offsetX(float dist, float halfHeight) + { + // The radius of the circle the carousel moves on. + const float circle_radius = 3; + float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist); + return (circle_radius - MathF.Sqrt(discriminant)) * halfHeight; + } + + private DisplayRange getDisplayRange() + { + Debug.Assert(carouselItems != null); + + // Find index range of all items that should be on-screen + carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload; + int firstIndex = carouselItems.BinarySearch(carouselBoundsItem); + if (firstIndex < 0) firstIndex = ~firstIndex; + + carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload; + int lastIndex = carouselItems.BinarySearch(carouselBoundsItem); + if (lastIndex < 0) lastIndex = ~lastIndex; + + firstIndex = Math.Max(0, firstIndex - 1); + lastIndex = Math.Max(0, lastIndex - 1); + + return new DisplayRange(firstIndex, lastIndex); + } + + private void updateDisplayedRange(DisplayRange range) + { + Debug.Assert(carouselItems != null); + + List toDisplay = range.Last - range.First == 0 + ? new List() + : carouselItems.GetRange(range.First, range.Last - range.First + 1); + + toDisplay.RemoveAll(i => !i.IsVisible); + + // Iterate over all panels which are already displayed and figure which need to be displayed / removed. + foreach (var panel in Scroll.Panels) + { + var carouselPanel = (ICarouselPanel)panel; + + // The case where we're intending to display this panel, but it's already displayed. + // Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation. + var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model); + + if (existing != null) + { + carouselPanel.Item = existing; + toDisplay.Remove(existing); + continue; + } + + // If the new display range doesn't contain the panel, it's no longer required for display. + expirePanelImmediately(panel); + } + + // Add any new items which need to be displayed and haven't yet. + foreach (var item in toDisplay) + { + var drawable = GetDrawableForDisplay(item); + + if (drawable is not ICarouselPanel carouselPanel) + throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}"); + + carouselPanel.DrawYPosition = item.CarouselYPosition; + carouselPanel.Item = item; + + Scroll.Add(drawable); + } + + // Update the total height of all items (to make the scroll container scrollable through the full height even though + // most items are not displayed / loaded). + if (carouselItems.Count > 0) + { + var lastItem = carouselItems[^1]; + Scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight + visibleHalfHeight)); + } + else + Scroll.SetLayoutHeight(0); + } + + private static void expirePanelImmediately(Drawable panel) + { + panel.FinishTransforms(); + panel.Expire(); + + var carouselPanel = (ICarouselPanel)panel; + + carouselPanel.Item = null; + carouselPanel.Selected.Value = false; + carouselPanel.KeyboardSelected.Value = false; + carouselPanel.Expanded.Value = false; + } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + // handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed). + if (invalidation.HasFlag(Invalidation.DrawSize)) + selectionValid.Invalidate(); + + return base.OnInvalidate(invalidation, source); + } + + #endregion + + #region Internal helper classes + + /// + /// Bookkeeping for a current selection. + /// + /// The selected model. If null, there's no selection. + /// A related carousel item representation for the model. May be null if selection is not present as an item, or if has not been run yet. + /// The Y position of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + /// The index of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); + + private record DisplayRange(int First, int Last); + + /// + /// Implementation of scroll container which handles very large vertical lists by internally using double precision + /// for pre-display Y values. + /// + protected partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler + { + public readonly Container Panels; + + public void SetLayoutHeight(float height) => Panels.Height = height; + + public CarouselScrollContainer() + { + // Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations, + // so we must maintain one level of separation from ScrollContent. + base.Add(Panels = new Container + { + Name = "Layout content", + RelativeSizeAxes = Axes.X, + }); + } + + public override void OffsetScrollPosition(double offset) + { + base.OffsetScrollPosition(offset); + + foreach (var panel in Panels) + { + var c = (ICarouselPanel)panel; + Debug.Assert(c.Item != null); + + c.DrawYPosition += offset; + } + } + + public override void Clear(bool disposeChildren) + { + Panels.Height = 0; + Panels.Clear(disposeChildren); + } + + public override void Add(Drawable drawable) + { + if (drawable is not ICarouselPanel) + throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}"); + + Panels.Add(drawable); + } + + public override double GetChildPosInContent(Drawable d, Vector2 offset) + { + if (d is not ICarouselPanel panel) + return base.GetChildPosInContent(d, offset); + + return panel.DrawYPosition + offset.X; + } + + protected override void ApplyCurrentToContent() + { + Debug.Assert(ScrollDirection == Direction.Vertical); + + double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y; + + foreach (var d in Panels) + d.Y = (float)(((ICarouselPanel)d).DrawYPosition + scrollableExtent); + } + + #region Absolute scrolling + + private bool absoluteScrolling; + + protected override bool IsDragging => base.IsDragging || absoluteScrolling; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + beginAbsoluteScrolling(e); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + endAbsoluteScrolling(); + break; + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) + return false; + + beginAbsoluteScrolling(e); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (absoluteScrolling) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + return true; + } + + return base.OnMouseMove(e); + } + + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + absoluteScrolling = true; + } + + private void endAbsoluteScrolling() => absoluteScrolling = false; + + #endregion + } + + #endregion + } +} diff --git a/osu.Game/Screens/SelectV2/CarouselItem.cs b/osu.Game/Screens/SelectV2/CarouselItem.cs new file mode 100644 index 0000000000..36dc48a497 --- /dev/null +++ b/osu.Game/Screens/SelectV2/CarouselItem.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// Represents a single display item for display in a . + /// This is used to house information related to the attached model that helps with display and tracking. + /// + public sealed class CarouselItem : IComparable + { + public const float DEFAULT_HEIGHT = 50; + + /// + /// The model this item is representing. + /// + public readonly object Model; + + /// + /// The current Y position in the carousel. + /// This is managed by and should not be set manually. + /// + public double CarouselYPosition { get; set; } + + /// + /// The height this item will take when displayed. Defaults to . + /// + public float DrawHeight { get; set; } = DEFAULT_HEIGHT; + + /// + /// Defines the display depth relative to other s. + /// + public int DepthLayer { get; set; } + + /// + /// Whether this item is visible or hidden. + /// + public bool IsVisible { get; set; } = true; + + /// + /// Whether this item is expanded or not. Should only be used for headers of groups. + /// + public bool IsExpanded { get; set; } + + public CarouselItem(object model) + { + Model = model; + } + + public int CompareTo(CarouselItem? other) + { + if (other == null) return 1; + + return CarouselYPosition.CompareTo(other.CarouselYPosition); + } + } +} diff --git a/osu.Game/Screens/SelectV2/ICarouselFilter.cs b/osu.Game/Screens/SelectV2/ICarouselFilter.cs new file mode 100644 index 0000000000..f510a7cd4b --- /dev/null +++ b/osu.Game/Screens/SelectV2/ICarouselFilter.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// An interface representing a filter operation which can be run on a . + /// + public interface ICarouselFilter + { + /// + /// Execute the filter operation. + /// + /// The items to be filtered. + /// A cancellation token. + /// The post-filtered items. + Task> Run(IEnumerable items, CancellationToken cancellationToken); + } +} diff --git a/osu.Game/Screens/SelectV2/ICarouselPanel.cs b/osu.Game/Screens/SelectV2/ICarouselPanel.cs new file mode 100644 index 0000000000..4fba0d2827 --- /dev/null +++ b/osu.Game/Screens/SelectV2/ICarouselPanel.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// An interface to be attached to any s which are used for display inside a . + /// Importantly, all properties in this interface are managed by and should not be written to elsewhere. + /// + public interface ICarouselPanel + { + /// + /// Whether this item has selection (see ). Should be read from to update the visual state. + /// + BindableBool Selected { get; } + + /// + /// Whether this item is expanded (see ). Should be read from to update the visual state. + /// + BindableBool Expanded { get; } + + /// + /// Whether this item has keyboard selection. Should be read from to update the visual state. + /// + BindableBool KeyboardSelected { get; } + + /// + /// Called when the panel is activated. Should be used to update the panel's visual state. + /// + void Activated(); + + /// + /// The Y position used internally for positioning in the carousel. + /// + double DrawYPosition { get; set; } + + /// + /// The carousel item this drawable is representing. Will be set before is called. + /// + CarouselItem? Item { get; set; } + } +} diff --git a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs index 732fb2cd8c..b54f007f38 100644 --- a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs +++ b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs @@ -339,6 +339,12 @@ namespace osu.Game.Screens.SelectV2.Leaderboards Origin = Anchor.CentreLeft, Size = new Vector2(24, 16), }, + new UpdateableTeamFlag(user.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new DateLabel(score.Date) { Anchor = Anchor.CentreLeft, @@ -774,11 +780,14 @@ namespace osu.Game.Screens.SelectV2.Leaderboards { List items = new List(); - if (score.Mods.Length > 0) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = score.Mods.Where(m => IsValidMod.Invoke(m)).ToArray())); + // system mods should never be copied across regardless of anything. + var copyableMods = score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); + + if (copyableMods.Length > 0) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); if (score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{score.OnlineID}"))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{score.OnlineID}"))); if (score.Files.Count <= 0) return items.ToArray(); diff --git a/osu.Game/Screens/SelectV2/PanelBase.cs b/osu.Game/Screens/SelectV2/PanelBase.cs new file mode 100644 index 0000000000..05a1a55c03 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBase.cs @@ -0,0 +1,289 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public abstract partial class PanelBase : PoolableDrawable, ICarouselPanel + { + private const float corner_radius = 10; + + private const float active_x_offset = 50f; + + protected const float DURATION = 400; + + protected float PanelXOffset { get; init; } + + private Box backgroundBorder = null!; + private Box backgroundGradient = null!; + private Box backgroundAccentGradient = null!; + private Container backgroundLayerHorizontalPadding = null!; + private Container backgroundContainer = null!; + private Container iconContainer = null!; + private Box activationFlash = null!; + private Box hoverLayer = null!; + private Box keyboardSelectionLayer = null!; + private Box selectionLayer = null!; + + public Container TopLevelContent { get; private set; } = null!; + + protected Container Content { get; private set; } = null!; + + public Drawable Background { set => backgroundContainer.Child = value; } + + public Drawable Icon { set => iconContainer.Child = value; } + + private Color4? accentColour; + + public Color4? AccentColour + { + get => accentColour; + set + { + accentColour = value; + updateDisplay(); + } + } + + // content is offset by PanelXOffset, make sure we only handle input at the actual visible + // offset region. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + TopLevelContent.ReceivePositionalInputAt(screenSpacePos); + + [Resolved] + private BeatmapCarousel? carousel { get; set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + RelativeSizeAxes = Axes.X; + Height = CarouselItem.DEFAULT_HEIGHT; + + InternalChild = TopLevelContent = new Container + { + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + X = corner_radius, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(1f), + Radius = 10, + }, + Children = new Drawable[] + { + new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + }, + backgroundLayerHorizontalPadding = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius, + Children = new Drawable[] + { + backgroundGradient = new Box + { + RelativeSizeAxes = Axes.Both, + }, + backgroundAccentGradient = new Box + { + RelativeSizeAxes = Axes.Both, + }, + backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + } + }, + } + }, + }, + iconContainer = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + }, + Content = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = PanelXOffset + corner_radius }, + }, + hoverLayer = new Box + { + Alpha = 0, + Colour = colours.Blue.Opacity(0.1f), + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + }, + selectionLayer = new Box + { + Alpha = 0, + Colour = ColourInfo.GradientHorizontal(colours.Yellow.Opacity(0), colours.Yellow.Opacity(0.5f)), + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0.7f, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + keyboardSelectionLayer = new Box + { + Alpha = 0, + Colour = colours.Yellow.Opacity(0.1f), + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + }, + activationFlash = new Box + { + Colour = Color4.White.Opacity(0.4f), + Blending = BlendingParameters.Additive, + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + }, + new HoverSounds(), + } + }; + + backgroundGradient.Colour = ColourInfo.GradientHorizontal(colourProvider.Background3, colourProvider.Background4); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => updateDisplay(), true); + + Selected.BindValueChanged(selected => + { + if (selected.NewValue) + selectionLayer.FadeIn(100, Easing.OutQuint); + else + selectionLayer.FadeOut(200, Easing.OutQuint); + + updateXOffset(); + }, true); + + KeyboardSelected.BindValueChanged(selected => + { + if (selected.NewValue) + keyboardSelectionLayer.FadeIn(100, Easing.OutQuint); + else + keyboardSelectionLayer.FadeOut(1000, Easing.OutQuint); + + updateXOffset(); + }, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + this.FadeInFromZero(DURATION, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + carousel?.Activate(Item!); + return true; + } + + private void updateDisplay() + { + var backgroundColour = accentColour ?? Color4.White; + var edgeEffectColour = accentColour ?? Color4Extensions.FromHex(@"4EBFFF"); + + backgroundAccentGradient.FadeColour(ColourInfo.GradientHorizontal(backgroundColour.Opacity(0.25f), backgroundColour.Opacity(0f)), DURATION, Easing.OutQuint); + backgroundBorder.FadeColour(backgroundColour, DURATION, Easing.OutQuint); + + TopLevelContent.FadeEdgeEffectTo(Expanded.Value ? edgeEffectColour.Opacity(0.5f) : Color4.Black.Opacity(0.4f), DURATION, Easing.OutQuint); + + updateXOffset(); + updateHover(); + } + + private void updateXOffset() + { + float x = PanelXOffset + corner_radius; + + if (!Expanded.Value && !Selected.Value) + x += active_x_offset; + + if (!KeyboardSelected.Value) + x += active_x_offset * 0.5f; + + TopLevelContent.MoveToX(x, DURATION, Easing.OutQuint); + } + + private void updateHover() + { + if (IsHovered) + hoverLayer.FadeIn(100, Easing.OutQuint); + else + hoverLayer.FadeOut(1000, Easing.OutQuint); + } + + protected override bool OnHover(HoverEvent e) + { + updateHover(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateHover(); + base.OnHoverLost(e); + } + + protected override void Update() + { + base.Update(); + Content.Padding = Content.Padding with { Left = iconContainer.DrawWidth }; + backgroundLayerHorizontalPadding.Padding = new MarginPadding { Left = iconContainer.DrawWidth }; + } + + #region ICarouselPanel + + public CarouselItem? Item { get; set; } + public BindableBool Selected { get; } = new BindableBool(); + public BindableBool Expanded { get; } = new BindableBool(); + public BindableBool KeyboardSelected { get; } = new BindableBool(); + + public double DrawYPosition { get; set; } + + public virtual void Activated() + { + activationFlash.FadeOutFromOne(1000, Easing.OutQuint); + } + + #endregion + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs new file mode 100644 index 0000000000..d4bf3519fa --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmap : PanelBase + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT; + + private StarCounter starCounter = null!; + private ConstrainedIconContainer difficultyIcon = null!; + private OsuSpriteText keyCountText = null!; + private StarRatingDisplay starRatingDisplay = null!; + private TopLocalRank difficultyRank = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText authorText = null!; + + private IBindable? starDifficultyBindable; + private CancellationTokenSource? starDifficultyCancellationSource; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + var inputRectangle = TopLevelContent.DrawRectangle; + + // Cover the gaps introduced by the spacing between BeatmapPanels so that clicks will not fall through the carousel. + // + // Caveat is that for simplicity, we are covering the full spacing, so panels with frontmost depth will have a slightly + // larger hit target. + inputRectangle = inputRectangle.Inflate(new MarginPadding { Vertical = BeatmapCarousel.SPACING }); + + return inputRectangle.Contains(TopLevelContent.ToLocalSpace(screenSpacePos)); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = HEIGHT; + + Icon = difficultyIcon = new ConstrainedIconContainer + { + Size = new Vector2(20), + Margin = new MarginPadding { Horizontal = 5f }, + Colour = colourProvider.Background5, + }; + + Content.Children = new[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Left = 10f }, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + difficultyRank = new TopLocalRank + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.75f) + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.4f) + } + } + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new[] + { + keyCountText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, + difficultyText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 8f }, + }, + authorText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + } + } + } + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => + { + computeStarRating(); + updateKeyCount(); + }); + + mods.BindValueChanged(_ => + { + computeStarRating(); + updateKeyCount(); + }, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + var beatmap = (BeatmapInfo)Item.Model; + + difficultyIcon.Icon = beatmap.Ruleset.CreateInstance().CreateIcon(); + + difficultyRank.Beatmap = beatmap; + difficultyText.Text = beatmap.DifficultyName; + authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + + computeStarRating(); + updateKeyCount(); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + difficultyRank.Beatmap = null; + starDifficultyBindable = null; + } + + private void computeStarRating() + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + var beatmap = (BeatmapInfo)Item.Model; + + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token); + starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateKeyCount() + { + if (Item == null) + return; + + var beatmap = (BeatmapInfo)Item.Model; + + if (ruleset.Value.OnlineID == 3) + { + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + + keyCountText.Alpha = 1; + keyCountText.Text = $"[{keyCount}K] "; + } + else + keyCountText.Alpha = 0; + } + + private void updateDisplay() + { + const float duration = 500; + + var starDifficulty = starDifficultyBindable?.Value ?? default; + + starRatingDisplay.Current.Value = starDifficulty; + starCounter.Current = (float)starDifficulty.Stars; + + difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + + var starRatingColour = colours.ForStarDifficulty(starDifficulty.Stars); + starCounter.FadeColour(starRatingColour, duration, Easing.OutQuint); + AccentColour = starRatingColour; + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs new file mode 100644 index 0000000000..c599c3e534 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmapSet : PanelBase + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + + private BeatmapSetPanelBackground background = null!; + + private OsuSpriteText titleText = null!; + private OsuSpriteText artistText = null!; + private Drawable chevronIcon = null!; + private UpdateBeatmapSetButton updateButton = null!; + private BeatmapSetOnlineStatusPill statusPill = null!; + private DifficultySpectrumDisplay difficultiesDisplay = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + public PanelBeatmapSet() + { + PanelXOffset = 20f; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = HEIGHT; + + Icon = chevronIcon = new Container + { + Size = new Vector2(0, 22), + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(12), + X = 1f, + Colour = colourProvider.Background5, + }, + }; + + Background = background = new BeatmapSetPanelBackground + { + RelativeSizeAxes = Axes.Both, + }; + + Content.Children = new[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Top = 7.5f, Left = 15, Bottom = 5 }, + Children = new Drawable[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true), + }, + artistText = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 5f }, + Children = new Drawable[] + { + updateButton = new UpdateBeatmapSetButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5f, Top = -2f }, + }, + statusPill = new BeatmapSetOnlineStatusPill + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + TextSize = 11, + TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, + Margin = new MarginPadding { Right = 5f }, + }, + difficultiesDisplay = new DifficultySpectrumDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }, + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + KeyboardSelected.BindValueChanged(k => KeyboardSelected.Value = k.NewValue, true); + } + + private void onExpanded() + { + if (Expanded.Value) + { + chevronIcon.ResizeWidthTo(18, 600, Easing.OutElasticQuarter); + chevronIcon.FadeTo(1f, DURATION, Easing.OutQuint); + } + else + { + chevronIcon.ResizeWidthTo(0f, DURATION, Easing.OutQuint); + chevronIcon.FadeTo(0f, DURATION, Easing.OutQuint); + } + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + var beatmapSet = (BeatmapSetInfo)Item.Model; + + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + background.Beatmap = beatmaps.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID)); + + titleText.Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title); + artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); + updateButton.BeatmapSet = beatmapSet; + statusPill.Status = beatmapSet.Status; + difficultiesDisplay.BeatmapSet = beatmapSet; + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + background.Beatmap = null; + updateButton.BeatmapSet = null; + difficultiesDisplay.BeatmapSet = null; + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs new file mode 100644 index 0000000000..948311a86e --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -0,0 +1,281 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmapStandalone : PanelBase + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + private IBindable? starDifficultyBindable; + private CancellationTokenSource? starDifficultyCancellationSource; + + private BeatmapSetPanelBackground background = null!; + + private OsuSpriteText titleText = null!; + private OsuSpriteText artistText = null!; + private UpdateBeatmapSetButton updateButton = null!; + private BeatmapSetOnlineStatusPill statusPill = null!; + + private ConstrainedIconContainer difficultyIcon = null!; + private FillFlowContainer difficultyLine = null!; + private StarRatingDisplay difficultyStarRating = null!; + private TopLocalRank difficultyRank = null!; + private OsuSpriteText difficultyKeyCountText = null!; + private OsuSpriteText difficultyName = null!; + private OsuSpriteText difficultyAuthor = null!; + + public PanelBeatmapStandalone() + { + PanelXOffset = 20; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = HEIGHT; + + Icon = difficultyIcon = new ConstrainedIconContainer + { + Size = new Vector2(20), + Margin = new MarginPadding { Horizontal = 5f }, + Colour = colourProvider.Background5, + }; + + Background = background = new BeatmapSetPanelBackground + { + RelativeSizeAxes = Axes.Both, + }; + + Content.Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Top = 7.5f, Left = 15, Bottom = 5 }, + Children = new Drawable[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true), + Shadow = true, + }, + artistText = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), + Shadow = true, + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 5f }, + Children = new Drawable[] + { + updateButton = new UpdateBeatmapSetButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5f, Top = -2f }, + }, + statusPill = new BeatmapSetOnlineStatusPill + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + TextSize = 11, + TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, + Margin = new MarginPadding { Right = 5f }, + }, + difficultyLine = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + difficultyStarRating = new StarRatingDisplay(default, StarRatingDisplaySize.Small) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(8f / 9f), + Margin = new MarginPadding { Right = 5f }, + }, + difficultyRank = new TopLocalRank + { + Scale = new Vector2(8f / 11), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5f }, + }, + difficultyKeyCountText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + Margin = new MarginPadding { Bottom = 2f }, + }, + difficultyName = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 5f, Bottom = 2f }, + }, + difficultyAuthor = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 5f, Bottom = 2f }, + } + } + }, + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => + { + computeStarRating(); + updateKeyCount(); + }); + + mods.BindValueChanged(_ => + { + computeStarRating(); + updateKeyCount(); + }, true); + + Selected.BindValueChanged(s => Expanded.Value = s.NewValue, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + var beatmap = (BeatmapInfo)Item.Model; + var beatmapSet = beatmap.BeatmapSet!; + + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + background.Beatmap = beatmaps.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID)); + + titleText.Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title); + artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); + updateButton.BeatmapSet = beatmapSet; + statusPill.Status = beatmapSet.Status; + + difficultyIcon.Icon = beatmap.Ruleset.CreateInstance().CreateIcon(); + difficultyIcon.Show(); + + difficultyRank.Beatmap = beatmap; + difficultyName.Text = beatmap.DifficultyName; + difficultyAuthor.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + difficultyLine.Show(); + + computeStarRating(); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + background.Beatmap = null; + updateButton.BeatmapSet = null; + difficultyRank.Beatmap = null; + starDifficultyBindable = null; + } + + private void computeStarRating() + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + var beatmap = (BeatmapInfo)Item.Model; + + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token); + starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateKeyCount() + { + if (Item == null) + return; + + var beatmap = (BeatmapInfo)Item.Model; + + if (ruleset.Value.OnlineID == 3) + { + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + + difficultyKeyCountText.Alpha = 1; + difficultyKeyCountText.Text = $"[{keyCount}K] "; + } + else + difficultyKeyCountText.Alpha = 0; + } + + private void updateDisplay() + { + const float duration = 500; + + var starDifficulty = starDifficultyBindable?.Value ?? default; + + AccentColour = colours.ForStarDifficulty(starDifficulty.Stars); + difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + difficultyStarRating.Current.Value = starDifficulty; + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroup.cs b/osu.Game/Screens/SelectV2/PanelGroup.cs new file mode 100644 index 0000000000..ecb64f4797 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroup.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroup : PanelBase + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT; + + private Drawable chevronIcon = null!; + private OsuSpriteText titleText = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = HEIGHT; + + Icon = chevronIcon = new SpriteIcon + { + AlwaysPresent = true, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + Margin = new MarginPadding { Horizontal = 5f }, + X = 2f, + Colour = colourProvider.Background3, + }; + Background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark1, + }; + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + titleText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + X = 10f, + }, + new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 20f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), + // TODO: requires Carousel/CarouselItem-side implementation + Text = "43", + UseFullGlyphHeight = false, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + private void onExpanded() + { + const float duration = 500; + + chevronIcon.ResizeWidthTo(Expanded.Value ? 12f : 0f, duration, Easing.OutQuint); + chevronIcon.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + GroupDefinition group = (GroupDefinition)Item.Model; + + titleText.Text = group.Title; + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs new file mode 100644 index 0000000000..0dc5a2f365 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroupStarDifficulty : PanelBase + { + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Drawable chevronIcon = null!; + private Box contentBackground = null!; + private StarRatingDisplay starRatingDisplay = null!; + private StarCounter starCounter = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = PanelGroup.HEIGHT; + + Icon = chevronIcon = new SpriteIcon + { + AlwaysPresent = true, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + Margin = new MarginPadding { Horizontal = 5f }, + X = 2f, + }; + Background = contentBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark1, + }; + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Left = 10f }, + Children = new Drawable[] + { + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(8f / 20f), + }, + } + }, + new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 20f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), + // TODO: requires Carousel/CarouselItem-side implementation + Text = "43", + UseFullGlyphHeight = false, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + int starNumber = (int)((GroupDefinition)Item.Model).Data; + + Color4 colour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber); + Color4 contentColour = starNumber >= 7 ? colours.Orange1 : colourProvider.Background5; + + AccentColour = colour; + contentBackground.Colour = colour.Darken(0.3f); + + starRatingDisplay.Current.Value = new StarDifficulty(starNumber, 0); + starCounter.Current = starNumber; + + chevronIcon.Colour = contentColour; + starCounter.Colour = contentColour; + } + + private void onExpanded() + { + const float duration = 500; + + chevronIcon.ResizeWidthTo(Expanded.Value ? 12f : 0f, duration, Easing.OutQuint); + chevronIcon.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs new file mode 100644 index 0000000000..e6ecdc6705 --- /dev/null +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class SoloSongSelect : SongSelect + { + protected override bool OnStart() + { + this.Push(new PlayerLoaderV2(() => new SoloPlayer())); + return false; + } + + private partial class PlayerLoaderV2 : PlayerLoader + { + public override bool ShowFooter => true; + + public PlayerLoaderV2(Func createPlayer) + : base(createPlayer) + { + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelectV2.cs b/osu.Game/Screens/SelectV2/SongSelect.cs similarity index 62% rename from osu.Game/Screens/SelectV2/SongSelectV2.cs rename to osu.Game/Screens/SelectV2/SongSelect.cs index 2f9667793f..ad29f846c4 100644 --- a/osu.Game/Screens/SelectV2/SongSelectV2.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +10,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; +using osu.Game.Screens.Select; using osu.Game.Screens.SelectV2.Footer; namespace osu.Game.Screens.SelectV2 @@ -20,15 +19,20 @@ namespace osu.Game.Screens.SelectV2 /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. /// This will be gradually built upon and ultimately replace once everything is in place. /// - public partial class SongSelectV2 : OsuScreen + public abstract partial class SongSelect : OsuScreen { private const float logo_scale = 0.4f; - private readonly ModSelectOverlay modSelectOverlay = new SoloModSelectOverlay(); + private readonly ModSelectOverlay modSelectOverlay = new ModSelectOverlay + { + ShowPresets = true, + }; [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + private BeatmapCarousel carousel = null!; + public override bool ShowFooter => true; [Resolved] @@ -39,6 +43,34 @@ namespace osu.Game.Screens.SelectV2 { AddRangeInternal(new Drawable[] { + new GridContainer // used for max width implementation + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 750), + }, + Content = new[] + { + new[] + { + Empty(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, + Child = carousel = new BeatmapCarousel + { + RequestPresentBeatmap = _ => OnStart(), + RelativeSizeAxes = Axes.Both + }, + }, + } + } + }, modSelectOverlay, }); } @@ -70,9 +102,13 @@ namespace osu.Game.Screens.SelectV2 base.OnEntering(e); } + private const double fade_duration = 300; + public override void OnResuming(ScreenTransitionEvent e) { - this.FadeIn(); + this.FadeIn(fade_duration, Easing.OutQuint); + + carousel.VisuallyFocusSelected = false; // required due to https://github.com/ppy/osu-framework/issues/3218 modSelectOverlay.SelectedMods.Disabled = false; @@ -83,16 +119,18 @@ namespace osu.Game.Screens.SelectV2 public override void OnSuspending(ScreenTransitionEvent e) { - this.Delay(400).FadeOut(); + this.Delay(100).FadeOut(fade_duration, Easing.OutQuint); modSelectOverlay.SelectedMods.UnbindFrom(Mods); + carousel.VisuallyFocusSelected = true; + base.OnSuspending(e); } public override bool OnExiting(ScreenExitEvent e) { - this.Delay(400).FadeOut(); + this.FadeOut(fade_duration, Easing.OutQuint); return base.OnExiting(e); } @@ -114,11 +152,17 @@ namespace osu.Game.Screens.SelectV2 logo.Action = () => { - this.Push(new PlayerLoaderV2(() => new SoloPlayer())); + OnStart(); return false; }; } + /// + /// Called when a selection is made. + /// + /// If a resultant action occurred that takes the user away from SongSelect. + protected abstract bool OnStart(); + protected override void LogoSuspending(OsuLogo logo) { base.LogoSuspending(logo); @@ -133,19 +177,17 @@ namespace osu.Game.Screens.SelectV2 logo.FadeOut(120, Easing.Out); } - private partial class SoloModSelectOverlay : UserModSelectOverlay + /// + /// Set the query to the search text box. + /// + /// The string to search. + public void Search(string query) { - protected override bool ShowPresets => true; - } - - private partial class PlayerLoaderV2 : PlayerLoader - { - public override bool ShowFooter => true; - - public PlayerLoaderV2(Func createPlayer) - : base(createPlayer) + carousel.Filter(new FilterCriteria { - } + // TODO: this should only set the text of the current criteria, not use a completely new criteria. + SearchText = query, + }); } } } diff --git a/osu.Game/Screens/SelectV2/TopLocalRank.cs b/osu.Game/Screens/SelectV2/TopLocalRank.cs new file mode 100644 index 0000000000..2a72a05db7 --- /dev/null +++ b/osu.Game/Screens/SelectV2/TopLocalRank.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osuTK; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class TopLocalRank : CompositeDrawable + { + private BeatmapInfo? beatmap; + + public BeatmapInfo? Beatmap + { + get => beatmap; + set + { + beatmap = value; + + if (IsLoaded) + updateSubscription(); + } + } + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IDisposable? scoreSubscription; + + private readonly UpdateableRank updateable; + + public TopLocalRank(BeatmapInfo? beatmap = null) + { + AutoSizeAxes = Axes.Both; + + InternalChild = updateable = new UpdateableRank + { + Size = new Vector2(40, 20), + Alpha = 0, + }; + + Beatmap = beatmap; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateSubscription(), true); + } + + private void updateSubscription() + { + scoreSubscription?.Dispose(); + + if (beatmap == null) + return; + + scoreSubscription = realm.RegisterForNotifications(r => + r.All() + .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1" + + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2" + + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmap.ID, ruleset.Value.ShortName), + localScoresChanged); + } + + private void localScoresChanged(IRealmCollection sender, ChangeSet? changes) + { + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + ScoreInfo? topScore = sender.MaxBy(info => (info.TotalScore, -info.Date.UtcDateTime.Ticks)); + updateable.Rank = topScore?.Rank; + updateable.Alpha = topScore != null ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + scoreSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/SelectV2/UpdateBeatmapSetButton.cs b/osu.Game/Screens/SelectV2/UpdateBeatmapSetButton.cs new file mode 100644 index 0000000000..e2c841f88a --- /dev/null +++ b/osu.Game/Screens/SelectV2/UpdateBeatmapSetButton.cs @@ -0,0 +1,198 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Screens.Select.Carousel; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class UpdateBeatmapSetButton : OsuAnimatedButton + { + private BeatmapSetInfo? beatmapSet; + + public BeatmapSetInfo? BeatmapSet + { + get => beatmapSet; + set + { + beatmapSet = value; + + if (IsLoaded) + beatmapChanged(); + } + } + + private SpriteIcon icon = null!; + private Box progressFill = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private LoginOverlay? loginOverlay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + public UpdateBeatmapSetButton() + { + Size = new Vector2(75f, 22f); + } + + private Bindable preferNoVideo = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + const float icon_size = 14; + + preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + + Content.Anchor = Anchor.Centre; + Content.Origin = Anchor.Centre; + Content.Shear = new Vector2(OsuGame.SHEAR, 0); + + Content.AddRange(new Drawable[] + { + progressFill = new Box + { + Colour = Color4.White, + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0, + }, + new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 5, Vertical = 3 }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Shear = new Vector2(-OsuGame.SHEAR, 0), + Children = new Drawable[] + { + new Container + { + Size = new Vector2(icon_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.SyncAlt, + Size = new Vector2(icon_size), + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Text = "Update", + } + } + }, + }); + + Action = performUpdate; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + beatmapChanged(); + } + + private void beatmapChanged() + { + Alpha = beatmapSet?.AllBeatmapsUpToDate == false ? 1 : 0; + icon.Spin(4000, RotationDirection.Clockwise); + } + + protected override bool OnHover(HoverEvent e) + { + icon.Spin(400, RotationDirection.Clockwise); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.Spin(4000, RotationDirection.Clockwise); + base.OnHoverLost(e); + } + + private bool updateConfirmed; + + private void performUpdate() + { + Debug.Assert(beatmapSet != null); + + if (!api.IsLoggedIn) + { + loginOverlay?.Show(); + return; + } + + if (dialogOverlay != null && beatmapSet.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) + { + dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => + { + updateConfirmed = true; + performUpdate(); + })); + + return; + } + + updateConfirmed = false; + + beatmapDownloader.DownloadAsUpdate(beatmapSet, preferNoVideo.Value); + attachExistingDownload(); + } + + private void attachExistingDownload() + { + Debug.Assert(beatmapSet != null); + var download = beatmapDownloader.GetExistingDownload(beatmapSet); + + if (download != null) + { + Enabled.Value = false; + TooltipText = string.Empty; + + download.DownloadProgressed += progress => progressFill.ResizeWidthTo(progress, 100, Easing.OutQuint); + download.Failure += _ => attachExistingDownload(); + } + else + { + Enabled.Value = true; + TooltipText = "Update beatmap with online changes"; + + progressFill.ResizeWidthTo(0, 100, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index ddc638b7c5..84b5889751 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Online.Spectator; using osu.Game.Replays; using osu.Game.Rulesets; @@ -38,6 +39,9 @@ namespace osu.Game.Screens.Spectate [Resolved] private SpectatorClient spectatorClient { get; set; } = null!; + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; @@ -50,6 +54,7 @@ namespace osu.Game.Screens.Spectate private readonly Dictionary gameplayStates = new Dictionary(); private IDisposable? realmSubscription; + private IDisposable? userWatchToken; /// /// Creates a new . @@ -64,6 +69,8 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); + userWatchToken = metadataClient.BeginWatchingUserPresence(); + userLookupCache.GetUsersAsync(users.ToArray()).ContinueWith(task => Schedule(() => { var foundUsers = task.GetResultSafely(); @@ -282,6 +289,7 @@ namespace osu.Game.Screens.Spectate } realmSubscription?.Dispose(); + userWatchToken?.Dispose(); } } } diff --git a/osu.Game/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index 9e04a238eb..0724327a9f 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -10,7 +10,7 @@ namespace osu.Game.Screens /// public abstract partial class StartupScreen : OsuScreen { - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool HideOverlaysOnEnter => true; diff --git a/osu.Game/Screens/Utility/CircleGameplay.cs b/osu.Game/Screens/Utility/CircleGameplay.cs index 1f970c5121..0f328d04fb 100644 --- a/osu.Game/Screens/Utility/CircleGameplay.cs +++ b/osu.Game/Screens/Utility/CircleGameplay.cs @@ -201,8 +201,8 @@ namespace osu.Game.Screens.Utility { double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); - approach.Scale = new Vector2(1 + 4 * (float)MathHelper.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); - Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + approach.Scale = new Vector2(1 + 4 * (float)Math.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); + Alpha = (float)Math.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); if (Clock.CurrentTime > HitTime + duration) Expire(); diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs index dcfcf602bf..ef1b848945 100644 --- a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -55,22 +56,22 @@ namespace osu.Game.Screens.Utility.SampleComponents { case Key.F: case Key.Up: - box.Y = MathHelper.Clamp(box.Y - movementAmount, 0.1f, 0.9f); + box.Y = Math.Clamp(box.Y - movementAmount, 0.1f, 0.9f); break; case Key.J: case Key.Down: - box.Y = MathHelper.Clamp(box.Y + movementAmount, 0.1f, 0.9f); + box.Y = Math.Clamp(box.Y + movementAmount, 0.1f, 0.9f); break; case Key.Z: case Key.Left: - box.X = MathHelper.Clamp(box.X - movementAmount, 0.1f, 0.9f); + box.X = Math.Clamp(box.X - movementAmount, 0.1f, 0.9f); break; case Key.X: case Key.Right: - box.X = MathHelper.Clamp(box.X + movementAmount, 0.1f, 0.9f); + box.X = Math.Clamp(box.X + movementAmount, 0.1f, 0.9f); break; } } diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs index 5038c53b4a..c0264f5734 100644 --- a/osu.Game/Screens/Utility/ScrollingGameplay.cs +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -165,7 +165,7 @@ namespace osu.Game.Screens.Utility { double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); - Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + Alpha = (float)Math.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); Y = judgement_position - (float)((HitTime - Clock.CurrentTime) / preempt); if (Clock.CurrentTime > HitTime + duration) diff --git a/osu.Game/Seasonal/IntroChristmas.cs b/osu.Game/Seasonal/IntroChristmas.cs new file mode 100644 index 0000000000..ac3286f277 --- /dev/null +++ b/osu.Game/Seasonal/IntroChristmas.cs @@ -0,0 +1,332 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Framework.Screens; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Menu; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class IntroChristmas : IntroScreen + { + // nekodex - circle the halls + public const string CHRISTMAS_BEATMAP_SET_HASH = "7e26183e72a496f672c3a21292e6b469fdecd084d31c259ea10a31df5b46cd77"; + + protected override string BeatmapHash => CHRISTMAS_BEATMAP_SET_HASH; + + protected override string BeatmapFile => "christmas2024.osz"; + + private const double beat_length = 60000 / 172.0; + private const double offset = 5924; + + protected override string SeeyaSampleName => "Intro/Welcome/seeya"; + + private TrianglesIntroSequence intro = null!; + + public IntroChristmas(Func? createNextScreen = null) + : base(createNextScreen) + { + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + PrepareMenuLoad(); + + var decouplingClock = new DecouplingFramedClock(UsingThemedIntro ? Track : null); + + LoadComponentAsync(intro = new TrianglesIntroSequence(logo, () => FadeInBackground()) + { + RelativeSizeAxes = Axes.Both, + Clock = new InterpolatingFramedClock(decouplingClock), + LoadMenu = LoadMenu + }, _ => + { + AddInternal(intro); + + // There is a chance that the intro timed out before being displayed, and this scheduled callback could + // happen during the outro rather than intro. + // In such a scenario, we don't want to play the intro sample, nor attempt to start the intro track + // (that may have already been since disposed by MusicController). + if (DidLoadMenu) + return; + + // If the user has requested no theme, fallback to the same intro voice and delay as IntroCircles. + // The triangles intro voice and theme are combined which makes it impossible to use. + StartTrack(); + + // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure. + decouplingClock.Start(); + }); + } + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + // important as there is a clock attached to a track which will likely be disposed before returning to this screen. + intro.Expire(); + } + + private partial class TrianglesIntroSequence : CompositeDrawable + { + private readonly OsuLogo logo; + private readonly Action showBackgroundAction; + private OsuSpriteText welcomeText = null!; + + private Container logoContainerSecondary = null!; + private LazerLogo lazerLogo = null!; + + private Drawable triangles = null!; + + public Action LoadMenu = null!; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + public TrianglesIntroSequence(OsuLogo logo, Action showBackgroundAction) + { + this.logo = logo; + this.showBackgroundAction = showBackgroundAction; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new[] + { + welcomeText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 10 }, + Font = OsuFont.GetFont(weight: FontWeight.Light, size: 42), + Alpha = 1, + Spacing = new Vector2(5), + }, + logoContainerSecondary = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = lazerLogo = new LazerLogo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }, + triangles = new CircularContainer + { + Alpha = 0, + Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(960), + Child = new GlitchingTriangles + { + RelativeSizeAxes = Axes.Both, + }, + } + }; + } + + private static double getTimeForBeat(int beat) => offset + beat_length * beat; + + protected override void LoadComplete() + { + base.LoadComplete(); + + lazerLogo.Hide(); + + using (BeginAbsoluteSequence(0)) + { + using (BeginDelayedSequence(getTimeForBeat(-16))) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); + + using (BeginDelayedSequence(getTimeForBeat(-15))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-14))) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); + + using (BeginDelayedSequence(getTimeForBeat(-13))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-12))) + welcomeText.FadeIn().OnComplete(t => t.Text = "merry christmas!"); + + using (BeginDelayedSequence(getTimeForBeat(-11))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-10))) + welcomeText.FadeIn().OnComplete(t => t.Text = "merry osumas!"); + + using (BeginDelayedSequence(getTimeForBeat(-9))) + { + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + } + + lazerLogo.Scale = new Vector2(0.2f); + triangles.Scale = new Vector2(0.2f); + + for (int i = 0; i < 8; i++) + { + using (BeginDelayedSequence(getTimeForBeat(-8 + i))) + { + triangles.FadeIn(); + + lazerLogo.ScaleTo(new Vector2(0.2f + (i + 1) / 8f * 0.3f), beat_length * 1, Easing.OutQuint); + triangles.ScaleTo(new Vector2(0.2f + (i + 1) / 8f * 0.3f), beat_length * 1, Easing.OutQuint); + lazerLogo.FadeTo((i + 1) * 0.06f); + lazerLogo.TransformTo(nameof(LazerLogo.Progress), (i + 1) / 10f); + } + } + + GameWideFlash flash = new GameWideFlash(); + + using (BeginDelayedSequence(getTimeForBeat(-2))) + { + lazerLogo.FadeIn().OnComplete(_ => game.Add(flash)); + } + + flash.FadeInCompleted = () => + { + logoContainerSecondary.Remove(lazerLogo, true); + triangles.FadeOut(); + logo.FadeIn(); + showBackgroundAction(); + LoadMenu(); + }; + } + } + + private partial class GameWideFlash : Box + { + public Action? FadeInCompleted; + + public GameWideFlash() + { + Colour = Color4.White; + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Alpha = 0; + + this.FadeTo(0.5f, beat_length * 2, Easing.In) + .OnComplete(_ => FadeInCompleted?.Invoke()); + + this.Delay(beat_length * 2) + .Then() + .FadeOutFromOne(3000, Easing.OutQuint); + } + } + + private partial class LazerLogo : CompositeDrawable + { + private LogoAnimation highlight = null!; + private LogoAnimation background = null!; + + public float Progress + { + get => background.AnimationProgress; + set + { + background.AnimationProgress = value; + highlight.AnimationProgress = value; + } + } + + public LazerLogo() + { + Size = new Vector2(960); + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + InternalChildren = new Drawable[] + { + highlight = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-highlight"), + Colour = Color4.White, + }, + background = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }, + }; + } + } + + private partial class GlitchingTriangles : BeatSyncedContainer + { + private int beatsHandled; + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + Divisor = beatsHandled < 4 ? 1 : 4; + + for (int i = 0; i < (beatsHandled + 1); i++) + { + float angle = (float)(RNG.NextDouble() * 2 * Math.PI); + float randomRadius = (float)(Math.Sqrt(RNG.NextDouble())); + + float x = 0.5f + 0.5f * randomRadius * (float)Math.Cos(angle); + float y = 0.5f + 0.5f * randomRadius * (float)Math.Sin(angle); + + Color4 christmasColour = RNG.NextBool() ? SeasonalUIConfig.PRIMARY_COLOUR_1 : SeasonalUIConfig.PRIMARY_COLOUR_2; + + Drawable triangle = new Triangle + { + Size = new Vector2(RNG.NextSingle() + 1.2f) * 80, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Both, + Position = new Vector2(x, y), + Colour = christmasColour + }; + + if (beatsHandled >= 10) + triangle.Blending = BlendingParameters.Additive; + + AddInternal(triangle); + triangle + .ScaleTo(0.9f) + .ScaleTo(1, beat_length / 2, Easing.Out); + triangle.FadeInFromZero(100, Easing.OutQuint); + } + + beatsHandled += 1; + } + } + } + } +} diff --git a/osu.Game/Seasonal/MainMenuSeasonalLighting.cs b/osu.Game/Seasonal/MainMenuSeasonalLighting.cs new file mode 100644 index 0000000000..30ad7acefe --- /dev/null +++ b/osu.Game/Seasonal/MainMenuSeasonalLighting.cs @@ -0,0 +1,206 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class MainMenuSeasonalLighting : CompositeDrawable + { + private IBindable working = null!; + + private InterpolatingFramedClock? beatmapClock; + + private List hitObjects = null!; + + private RulesetInfo? osuRuleset; + + private int? lastObjectIndex; + + public MainMenuSeasonalLighting() + { + // match beatmap playfield + RelativeChildSize = new Vector2(512, 384); + + RelativeSizeAxes = Axes.X; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(IBindable working, RulesetStore rulesets) + { + // operate in osu! ruleset to keep things simple for now. + osuRuleset = rulesets.GetRuleset(0); + + this.working = working.GetBoundCopy(); + this.working.BindValueChanged(_ => Scheduler.AddOnce(updateBeatmap), true); + } + + private void updateBeatmap() + { + lastObjectIndex = null; + + if (osuRuleset == null) + { + beatmapClock = new InterpolatingFramedClock(Clock); + hitObjects = new List(); + return; + } + + // Intentionally maintain separately so the lighting is not in audio clock space (it shouldn't rewind etc.) + beatmapClock = new InterpolatingFramedClock(new FramedClock(working.Value.Track)); + + hitObjects = working.Value + .GetPlayableBeatmap(osuRuleset) + .HitObjects + .SelectMany(h => h.NestedHitObjects.Prepend(h)) + .OrderBy(h => h.StartTime) + .ToList(); + } + + protected override void Update() + { + base.Update(); + + if (osuRuleset == null || beatmapClock == null) + return; + + Height = DrawWidth / 16 * 10; + + beatmapClock.ProcessFrame(); + + // intentionally slightly early since we are doing fades on the lighting. + double time = beatmapClock.CurrentTime + 50; + + // handle seeks or OOB by skipping to current. + if (lastObjectIndex == null || lastObjectIndex >= hitObjects.Count || (lastObjectIndex >= 0 && hitObjects[lastObjectIndex.Value].StartTime > time) + || Math.Abs(beatmapClock.ElapsedFrameTime) > 500) + lastObjectIndex = hitObjects.Count(h => h.StartTime < time) - 1; + + while (lastObjectIndex < hitObjects.Count - 1) + { + var h = hitObjects[lastObjectIndex.Value + 1]; + + if (h.StartTime > time) + break; + + // Don't add lighting if the game is running too slow. + if (Clock.ElapsedFrameTime < 20) + addLight(h); + + lastObjectIndex++; + } + } + + private void addLight(HitObject h) + { + var light = new Light + { + RelativePositionAxes = Axes.Both, + Position = ((IHasPosition)h).Position + }; + + AddInternal(light); + + if (h.GetType().Name.Contains("Tick")) + { + light.Colour = SeasonalUIConfig.AMBIENT_COLOUR_1; + light.Scale = new Vector2(0.5f); + light + .FadeInFromZero(250) + .Then() + .FadeOutFromOne(1000, Easing.Out); + + light.MoveToOffset(new Vector2(RNG.Next(-20, 20), RNG.Next(-20, 20)), 1400, Easing.Out); + } + else + { + // default are green + Color4 col = SeasonalUIConfig.PRIMARY_COLOUR_2; + + // whistles are red + if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_WHISTLE)) + col = SeasonalUIConfig.PRIMARY_COLOUR_1; + // clap is third ambient (yellow) colour + else if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)) + col = SeasonalUIConfig.AMBIENT_COLOUR_1; + + light.Colour = col; + + // finish results in larger lighting + if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH)) + light.Scale = new Vector2(3); + + light + .FadeInFromZero(150) + .Then() + .FadeOutFromOne(1000, Easing.In); + } + + light.Expire(); + } + + private partial class Light : CompositeDrawable + { + private readonly Circle circle; + + public new Color4 Colour + { + set + { + circle.Colour = value.Darken(0.8f); + circle.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = value, + Radius = 80, + }; + } + } + + public Light() + { + InternalChildren = new Drawable[] + { + circle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12), + Colour = SeasonalUIConfig.AMBIENT_COLOUR_1, + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = SeasonalUIConfig.AMBIENT_COLOUR_2, + Radius = 80, + } + } + }; + + Origin = Anchor.Centre; + Alpha = 0.5f; + } + } + } +} diff --git a/osu.Game/Seasonal/OsuLogoChristmas.cs b/osu.Game/Seasonal/OsuLogoChristmas.cs new file mode 100644 index 0000000000..8975a69c32 --- /dev/null +++ b/osu.Game/Seasonal/OsuLogoChristmas.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Screens.Menu; +using osuTK; + +namespace osu.Game.Seasonal +{ + public partial class OsuLogoChristmas : OsuLogo + { + protected override double BeatSampleVariance => 0.02; + + private Sprite? hat; + + private bool hasHat; + + protected override MenuLogoVisualisation CreateMenuLogoVisualisation() => new SeasonalMenuLogoVisualisation(); + + [BackgroundDependencyLoader] + private void load(TextureStore textures, AudioManager audio) + { + LogoElements.Add(hat = new Sprite + { + BypassAutoSizeAxes = Axes.Both, + Alpha = 0, + Origin = Anchor.BottomCentre, + Scale = new Vector2(-1, 1), + Texture = textures.Get(@"Menu/hat"), + }); + + // override base samples with our preferred ones. + SampleDownbeat = SampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat-bell"); + } + + protected override void Update() + { + base.Update(); + updateHat(); + } + + private void updateHat() + { + if (hat == null) + return; + + bool shouldHat = DrawWidth * Scale.X < 400; + + if (shouldHat != hasHat) + { + hasHat = shouldHat; + + if (hasHat) + { + hat.Delay(400) + .Then() + .MoveTo(new Vector2(120, 160)) + .RotateTo(0) + .RotateTo(-20, 500, Easing.OutQuint) + .FadeIn(250, Easing.OutQuint); + } + else + { + hat.Delay(100) + .Then() + .MoveToOffset(new Vector2(0, -5), 500, Easing.OutQuint) + .FadeOut(500, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.cs b/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.cs new file mode 100644 index 0000000000..f00da3fe7e --- /dev/null +++ b/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Screens.Menu; + +namespace osu.Game.Seasonal +{ + internal partial class SeasonalMenuLogoVisualisation : MenuLogoVisualisation + { + protected override void UpdateColour() => Colour = SeasonalUIConfig.AMBIENT_COLOUR_1; + } +} diff --git a/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs b/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs new file mode 100644 index 0000000000..46a0a973bb --- /dev/null +++ b/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Utils; +using osu.Game.Screens.Menu; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class SeasonalMenuSideFlashes : MenuSideFlashes + { + protected override bool RefreshColoursEveryFlash => true; + + protected override float Intensity => 4; + + protected override Color4 GetBaseColour() => RNG.NextBool() ? SeasonalUIConfig.PRIMARY_COLOUR_1 : SeasonalUIConfig.PRIMARY_COLOUR_2; + } +} diff --git a/osu.Game/Seasonal/SeasonalUIConfig.cs b/osu.Game/Seasonal/SeasonalUIConfig.cs new file mode 100644 index 0000000000..b894a42108 --- /dev/null +++ b/osu.Game/Seasonal/SeasonalUIConfig.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + /// + /// General configuration setting for seasonal event adjustments to the game. + /// + public static class SeasonalUIConfig + { + public static readonly bool ENABLED = false; + + public static readonly Color4 PRIMARY_COLOUR_1 = Color4Extensions.FromHex(@"D32F2F"); + + public static readonly Color4 PRIMARY_COLOUR_2 = Color4Extensions.FromHex(@"388E3C"); + + public static readonly Color4 AMBIENT_COLOUR_1 = Color4Extensions.FromHex(@"FFFFCC"); + + public static readonly Color4 AMBIENT_COLOUR_2 = Color4Extensions.FromHex(@"FFE4B5"); + } +} diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 771d10d73b..bd31ccd5c9 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -7,7 +7,6 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; @@ -110,15 +109,37 @@ namespace osu.Game.Skinning case GlobalSkinnableContainers.MainHUDComponents: if (containerLookup.Ruleset != null) { - return new Container + return new DefaultSkinComponentsContainer(container => + { + var comboCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(36, -66); + + if (comboCounter != null) + { + comboCounter.Position = pos; + pos -= new Vector2(0, comboCounter.DrawHeight * 1.4f + 20); + } + + if (spectatorList != null) + spectatorList.Position = pos; + }) { RelativeSizeAxes = Axes.Both, - Child = new ArgonComboCounter + Children = new Drawable[] { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Position = new Vector2(36, -66), - Scale = new Vector2(1.3f), + new ArgonComboCounter + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Scale = new Vector2(1.3f), + }, + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } }, }; } diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index f1c27434fa..d9f7eedfb5 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -226,7 +226,7 @@ namespace osu.Game.Skinning.Components return computeDifficulty().ApproachRate.ToLocalisableString(@"0.##"); case BeatmapAttribute.StarRating: - return (starDifficulty?.Stars ?? 0).ToLocalisableString(@"F2"); + return (starDifficulty?.Stars ?? 0).FormatStarRating(); case BeatmapAttribute.MaxPP: return Math.Round(starDifficulty?.PerformanceAttributes?.Total ?? 0, MidpointRounding.AwayFromZero).ToLocalisableString(); diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs index db1f216b6e..1e6fa44e68 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -41,6 +41,7 @@ namespace osu.Game.Skinning public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; public float ComboPosition = 111 * POSITION_SCALE_FACTOR; public float ScorePosition = 300 * POSITION_SCALE_FACTOR; + public float BarLineHeight = 1; public bool ShowJudgementLine = true; public bool KeysUnderNotes; public int LightFramePerSecond = 60; diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index ee354de68b..e94fb23681 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -70,6 +70,9 @@ namespace osu.Game.Skinning RightStageImage, BottomStageImage, + BarLineHeight, + BarLineColour, + // ReSharper disable once InconsistentNaming Hit300g, diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 09866ef237..2739743387 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -86,6 +86,10 @@ namespace osu.Game.Skinning parseArrayValue(pair.Value, currentConfig.ColumnWidth); break; + case "BarlineHeight": + currentConfig.BarLineHeight = float.Parse(pair.Value, CultureInfo.InvariantCulture); + break; + case "HitPosition": currentConfig.HitPosition = (480 - Math.Clamp(float.Parse(pair.Value, CultureInfo.InvariantCulture), 240, 480)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; break; diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 6faadfba9b..51c1473303 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -198,9 +198,15 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.ComboBreakColour: return SkinUtils.As(getCustomColour(existing, "ColourBreak")); + case LegacyManiaSkinConfigurationLookups.BarLineColour: + return SkinUtils.As(getCustomColour(existing, "ColourBarline")); + case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); + case LegacyManiaSkinConfigurationLookups.BarLineHeight: + return SkinUtils.As(new Bindable(existing.BarLineHeight)); + case LegacyManiaSkinConfigurationLookups.NoteBodyStyle: if (existing.NoteBodyStyle != null) @@ -367,16 +373,29 @@ namespace osu.Game.Skinning return new DefaultSkinComponentsContainer(container => { var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(); if (combo != null) { combo.Anchor = Anchor.BottomLeft; combo.Origin = Anchor.BottomLeft; combo.Scale = new Vector2(1.28f); + + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = pos; } }) { - new LegacyDefaultComboCounter() + new LegacyDefaultComboCounter(), + new SpectatorList(), }; } diff --git a/osu.Game/Skinning/ResourceStoreBackedSkin.cs b/osu.Game/Skinning/ResourceStoreBackedSkin.cs index 206c400a88..450794c4a8 100644 --- a/osu.Game/Skinning/ResourceStoreBackedSkin.cs +++ b/osu.Game/Skinning/ResourceStoreBackedSkin.cs @@ -33,7 +33,7 @@ namespace osu.Game.Skinning public ISample? GetSample(ISampleInfo sampleInfo) { - foreach (string? lookup in sampleInfo.LookupNames) + foreach (string lookup in sampleInfo.LookupNames) { ISample? sample = samples.Get(lookup); if (sample != null) diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 59c7f0ba26..70d3195ecd 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -37,7 +37,7 @@ namespace osu.Game.Skinning protected override string[] HashableFileTypes => new[] { ".ini", ".json" }; - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == @".osk"; + protected override bool ShouldDeleteArchive(string path) => string.Equals(Path.GetExtension(path), @".osk", StringComparison.OrdinalIgnoreCase); protected override SkinInfo CreateModel(ArchiveReader archive, ImportParameters parameters) => new SkinInfo { Name = archive.Name ?? @"No name" }; diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 9effb483c4..47618f6296 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; +using System.IO; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,6 +14,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Sprites; using osu.Game.Localisation.SkinComponents; using osu.Game.Overlays.Settings; +using osu.Game.Utils; using osuTK; namespace osu.Game.Skinning @@ -93,10 +94,10 @@ namespace osu.Game.Skinning // but that requires further thought. var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin; - string[]? availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files - .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal) - || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)) - .Select(f => f.Filename).Distinct()).ToArray(); + string[]? availableFiles = highestPrioritySkin?.SkinInfo.PerformRead( + s => s.Files + .Where(f => SupportedExtensions.IMAGE_EXTENSIONS.Contains(Path.GetExtension(f.Filename).ToLowerInvariant())) + .Select(f => f.Filename).Distinct()).ToArray(); if (availableFiles?.Length > 0) Items = availableFiles; diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index d562fd3256..a4a967bed9 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; +using osu.Game.Graphics; using osu.Game.IO; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; @@ -90,6 +91,7 @@ namespace osu.Game.Skinning var ppCounter = container.OfType().FirstOrDefault(); var songProgress = container.OfType().FirstOrDefault(); var keyCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); if (score != null) { @@ -142,17 +144,26 @@ namespace osu.Game.Skinning } } + const float padding = 10; + + // Hard to find this at runtime, so taken from the most expanded state during replay. + const float song_progress_offset_height = 73; + if (songProgress != null && keyCounter != null) { - const float padding = 10; - - // Hard to find this at runtime, so taken from the most expanded state during replay. - const float song_progress_offset_height = 73; - keyCounter.Anchor = Anchor.BottomRight; keyCounter.Origin = Anchor.BottomRight; keyCounter.Position = new Vector2(-padding, -(song_progress_offset_height + padding)); } + + if (spectatorList != null) + { + spectatorList.HeaderFont.Value = Typeface.Venera; + spectatorList.HeaderColour.Value = new OsuColour().BlueLighter; + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(padding, -(song_progress_offset_height + padding)); + } }) { Children = new Drawable[] @@ -165,7 +176,8 @@ namespace osu.Game.Skinning new DefaultKeyCounterDisplay(), new BarHitErrorMeter(), new BarHitErrorMeter(), - new TrianglesPerformancePointsCounter() + new TrianglesPerformancePointsCounter(), + new SpectatorList(), } }; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 8e7b3feacf..5ef6b30a82 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -35,7 +35,7 @@ namespace osu.Game.Storyboards.Drawables protected override Container Content { get; } - protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480); + protected override Vector2 DrawScale => new Vector2((Parent?.DrawHeight ?? 0) / 480); public override bool RemoveCompletedTransforms => false; @@ -67,7 +67,7 @@ namespace osu.Game.Storyboards.Drawables bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).All(e => e is StoryboardVideo); - Width = Height * (storyboard.BeatmapInfo.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); + Width = Height * (storyboard.Beatmap.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 8c43b99702..4cad1ba4ad 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.IO; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Storyboards.Drawables; +using osu.Game.Utils; namespace osu.Game.Storyboards { @@ -16,6 +18,7 @@ namespace osu.Game.Storyboards public IEnumerable Layers => layers.Values; public BeatmapInfo BeatmapInfo = new BeatmapInfo(); + public IBeatmap Beatmap { get; set; } = new Beatmap(); /// /// Whether the storyboard should prefer textures from the current skin before using local storyboard textures. @@ -89,15 +92,13 @@ namespace osu.Game.Storyboards // Importantly, do this after the NullOrEmpty because EF may have stored the non-nullable value as null to the database, bypassing compile-time constraints. backgroundPath = backgroundPath.ToLowerInvariant(); - return GetLayer("Background").Elements.Any(e => e.Path.ToLowerInvariant() == backgroundPath); + return GetLayer("Background").Elements.Any(e => string.Equals(e.Path, backgroundPath, StringComparison.OrdinalIgnoreCase)); } } public virtual DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => new DrawableStoryboard(this, mods); - private static readonly string[] image_extensions = { @".png", @".jpg" }; - public virtual string? GetStoragePathFromStoryboardPath(string path) { string? resolvedPath = null; @@ -109,7 +110,7 @@ namespace osu.Game.Storyboards else { // Some old storyboards don't include a file extension, so let's best guess at one. - foreach (string ext in image_extensions) + foreach (string ext in SupportedExtensions.IMAGE_EXTENSIONS) { if ((resolvedPath = BeatmapInfo.BeatmapSet?.GetPathForFile($"{path}{ext}")) != null) break; diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 31ad2de62e..1f0c08714e 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -27,6 +27,17 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo = baseBeatmap.BeatmapInfo; ControlPointInfo = baseBeatmap.ControlPointInfo; UnhandledEventLines = baseBeatmap.UnhandledEventLines; + AudioLeadIn = baseBeatmap.AudioLeadIn; + StackLeniency = baseBeatmap.StackLeniency; + SpecialStyle = baseBeatmap.SpecialStyle; + LetterboxInBreaks = baseBeatmap.LetterboxInBreaks; + WidescreenStoryboard = baseBeatmap.WidescreenStoryboard; + EpilepsyWarning = baseBeatmap.EpilepsyWarning; + SamplesMatchPlaybackRate = baseBeatmap.SamplesMatchPlaybackRate; + DistanceSpacing = baseBeatmap.DistanceSpacing; + GridSize = baseBeatmap.GridSize; + TimelineZoom = baseBeatmap.TimelineZoom; + CountdownOffset = baseBeatmap.CountdownOffset; if (withHitObjects) { diff --git a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs new file mode 100644 index 0000000000..eaef2af7c8 --- /dev/null +++ b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Database; + +namespace osu.Game.Tests.Beatmaps +{ + internal partial class TestBeatmapStore : BeatmapStore + { + public readonly BindableList BeatmapSets = new BindableList(); + public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy(); + } +} diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index 00e5b38b1a..df4a91c4e2 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests [CallerMemberName] string callingMethodName = @"") : base($"{callingMethodName}-{Guid.NewGuid()}", new HostOptions { - IPCPort = bindIPC ? OsuGame.IPC_PORT : null, + IPCPipeName = bindIPC ? OsuGame.IPC_PIPE_NAME : null, }, bypassCleanup: bypassCleanupOnDispose, realtime: realtime) { this.bypassCleanupOnSetup = bypassCleanupOnSetup; diff --git a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs index 6908f7f1b4..21d0b8e7a8 100644 --- a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs +++ b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs @@ -658,11 +658,10 @@ namespace osu.Game.Tests.Visual.Gameplay private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowPresets => false; - public TestModSelectOverlay() : base(OverlayColourScheme.Aquamarine) { + ShowPresets = false; } } } diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 4a862750bc..dca1b0e468 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -16,11 +16,14 @@ namespace osu.Game.Tests.Visual.Metadata public override IBindable IsConnected => isConnected; private readonly BindableBool isConnected = new BindableBool(true); - public override IBindable IsWatchingUserPresence => isWatchingUserPresence; - private readonly BindableBool isWatchingUserPresence = new BindableBool(); + public override UserPresence LocalUserPresence => localUserPresence; + private UserPresence localUserPresence; - public override IBindableDictionary UserStates => userStates; - private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary UserPresences => userPresences; + private readonly BindableDictionary userPresences = new BindableDictionary(); + + public override IBindableDictionary FriendPresences => friendPresences; + private readonly BindableDictionary friendPresences = new BindableDictionary(); public override Bindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); @@ -28,25 +31,29 @@ namespace osu.Game.Tests.Visual.Metadata [Resolved] private IAPIProvider api { get; set; } = null!; - public override Task BeginWatchingUserPresence() + public event Action? OnBeginWatchingUserPresence; + public event Action? OnEndWatchingUserPresence; + + protected override Task BeginWatchingUserPresenceInternal() { - isWatchingUserPresence.Value = true; + OnBeginWatchingUserPresence?.Invoke(); return Task.CompletedTask; } - public override Task EndWatchingUserPresence() + protected override Task EndWatchingUserPresenceInternal() { - isWatchingUserPresence.Value = false; + OnEndWatchingUserPresence?.Invoke(); return Task.CompletedTask; } public override Task UpdateActivity(UserActivity? activity) { - if (isWatchingUserPresence.Value) + localUserPresence = localUserPresence with { Activity = activity }; + + if (IsWatchingUserPresence) { - userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence); - localUserPresence = localUserPresence with { Activity = activity }; - userStates[api.LocalUser.Value.Id] = localUserPresence; + if (userPresences.ContainsKey(api.LocalUser.Value.Id)) + userPresences[api.LocalUser.Value.Id] = localUserPresence; } return Task.CompletedTask; @@ -54,11 +61,12 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UpdateStatus(UserStatus? status) { - if (isWatchingUserPresence.Value) + localUserPresence = localUserPresence with { Status = status }; + + if (IsWatchingUserPresence) { - userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence); - localUserPresence = localUserPresence with { Status = status }; - userStates[api.LocalUser.Value.Id] = localUserPresence; + if (userPresences.ContainsKey(api.LocalUser.Value.Id)) + userPresences[api.LocalUser.Value.Id] = localUserPresence; } return Task.CompletedTask; @@ -66,17 +74,37 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value) + if (IsWatchingUserPresence) { - if (presence.HasValue) - userStates[userId] = presence.Value; + if (presence?.Status != null) + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = presence.Value; + else + userPresences[userId] = presence.Value; + } else - userStates.Remove(userId); + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = default; + else + userPresences.Remove(userId); + } } return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + if (presence.HasValue) + friendPresences[userId] = presence.Value; + else + friendPresences.Remove(userId); + + return Task.CompletedTask; + } + public override Task GetChangesSince(int queueId) => Task.FromResult(new BeatmapUpdates(Array.Empty(), queueId)); diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index efd0b80ebf..262816ae89 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; @@ -17,11 +16,6 @@ namespace osu.Game.Tests.Visual.Multiplayer /// TestMultiplayerClient MultiplayerClient { get; } - /// - /// The cached . - /// - new TestMultiplayerRoomManager RoomManager { get; } - /// /// The cached . /// diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 42cf317829..ac587d3bb2 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; @@ -17,21 +18,13 @@ namespace osu.Game.Tests.Visual.Multiplayer public const int PLAYER_2_ID = 56; public TestMultiplayerClient MultiplayerClient => OnlinePlayDependencies.MultiplayerClient; - public new TestMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public TestSpectatorClient SpectatorClient => OnlinePlayDependencies.SpectatorClient; protected new MultiplayerTestSceneDependencies OnlinePlayDependencies => (MultiplayerTestSceneDependencies)base.OnlinePlayDependencies; public bool RoomJoined => MultiplayerClient.RoomJoined; - private readonly bool joinRoom; - - protected MultiplayerTestScene(bool joinRoom = true) - { - this.joinRoom = joinRoom; - } - - protected virtual Room CreateRoom() + protected Room CreateDefaultRoom() { return new Room { @@ -47,21 +40,12 @@ namespace osu.Game.Tests.Visual.Multiplayer }; } - public override void SetUpSteps() - { - base.SetUpSteps(); + /// + /// Creates and joins a basic multiplayer room. + /// + protected void JoinRoom(Room room) => MultiplayerClient.CreateRoom(room).FireAndForget(); - if (joinRoom) - { - AddStep("join room", () => - { - SelectedRoom.Value = CreateRoom(); - RoomManager.CreateRoom(SelectedRoom.Value); - }); - - AddUntilStep("wait for room join", () => RoomJoined); - } - } + protected void WaitForJoined() => AddUntilStep("wait for room join", () => RoomJoined); protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new MultiplayerTestSceneDependencies(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs index 88202d4327..24c33f2f49 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -3,7 +3,6 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; -using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; @@ -16,19 +15,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { public TestMultiplayerClient MultiplayerClient { get; } public TestSpectatorClient SpectatorClient { get; } - public new TestMultiplayerRoomManager RoomManager => (TestMultiplayerRoomManager)base.RoomManager; public MultiplayerTestSceneDependencies() { - MultiplayerClient = new TestMultiplayerClient(RoomManager); + MultiplayerClient = new TestMultiplayerClient(RequestsHandler); SpectatorClient = CreateSpectatorClient(); CacheAs(MultiplayerClient); CacheAs(SpectatorClient); } - protected override IRoomManager CreateRoomManager() => new TestMultiplayerRoomManager(RequestsHandler); - protected virtual TestSpectatorClient CreateSpectatorClient() => new TestSpectatorClient(); } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 4d812abf11..febd7f54ff 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -10,6 +10,7 @@ using MessagePack; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -17,6 +18,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { @@ -65,15 +67,15 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private IAPIProvider api { get; set; } = null!; - private readonly TestMultiplayerRoomManager roomManager; - private MultiplayerPlaylistItem? currentItem => ServerRoom?.Playlist[currentIndex]; private int currentIndex; private long lastPlaylistItemId; - public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + private readonly TestRoomRequestsHandler apiRequestHandler; + + public TestMultiplayerClient(TestRoomRequestsHandler? apiRequestHandler = null) { - this.roomManager = roomManager; + this.apiRequestHandler = apiRequestHandler ?? new TestRoomRequestsHandler(); } public void Connect() => isConnected.Value = true; @@ -206,7 +208,7 @@ namespace osu.Game.Tests.Visual.Multiplayer ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(clone(userId), clone(user.BeatmapAvailability)); } - protected override async Task JoinRoom(long roomId, string? password = null) + protected override async Task JoinRoomInternal(long roomId, string? password = null) { if (RoomJoined || ServerAPIRoom != null) throw new InvalidOperationException("Already joined a room"); @@ -214,7 +216,7 @@ namespace osu.Game.Tests.Visual.Multiplayer roomId = clone(roomId); password = clone(password); - ServerAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID == roomId); + ServerAPIRoom = ServerSideRooms.Single(r => r.RoomID == roomId); if (password != ServerAPIRoom.Password) throw new InvalidOperationException("Invalid password."); @@ -236,7 +238,7 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = ServerAPIRoom.QueueMode, AutoStartDuration = ServerAPIRoom.AutoStartDuration }, - Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(), + Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, Host = localUser }; @@ -335,6 +337,23 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public override Task ChangeUserStyle(int? beatmapId, int? rulesetId) + { + ChangeUserStyle(api.LocalUser.Value.Id, beatmapId, rulesetId); + return Task.CompletedTask; + } + + public void ChangeUserStyle(int userId, int? beatmapId, int? rulesetId) + { + Debug.Assert(ServerRoom != null); + + var user = ServerRoom.Users.Single(u => u.UserID == userId); + user.BeatmapId = beatmapId; + user.RulesetId = rulesetId; + + ((IMultiplayerClient)this).UserStyleChanged(userId, beatmapId, rulesetId); + } + public void ChangeUserMods(int userId, IEnumerable newMods) => ChangeUserMods(userId, newMods.Select(m => new APIMod(m))); @@ -483,6 +502,19 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId)); + protected override Task CreateRoomInternal(MultiplayerRoom room) + { + Room apiRoom = new Room(room) + { + Type = room.Settings.MatchType == MatchType.Playlists + ? MatchType.HeadToHead + : room.Settings.MatchType + }; + + AddServerSideRoom(apiRoom, api.LocalUser.Value); + return JoinRoomInternal(apiRoom.RoomID!.Value, room.Settings.Password); + } + private async Task changeMatchType(MatchType type) { Debug.Assert(ServerRoom != null); @@ -655,25 +687,23 @@ namespace osu.Game.Tests.Visual.Multiplayer return MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); } - public static MultiplayerPlaylistItem CreateMultiplayerPlaylistItem(PlaylistItem item) => new MultiplayerPlaylistItem - { - ID = item.ID, - OwnerID = item.OwnerID, - BeatmapID = item.Beatmap.OnlineID, - BeatmapChecksum = item.Beatmap.MD5Hash, - RulesetID = item.RulesetID, - RequiredMods = item.RequiredMods.ToArray(), - AllowedMods = item.AllowedMods.ToArray(), - Expired = item.Expired, - PlaylistOrder = item.PlaylistOrder ?? 0, - PlayedAt = item.PlayedAt, - StarRating = item.Beatmap.StarRating, - }; - public override Task DisconnectInternal() { isConnected.Value = false; return Task.CompletedTask; } + + #region API Room Handling + + public IReadOnlyList ServerSideRooms + => apiRequestHandler.ServerSideRooms; + + public void AddServerSideRoom(Room room, APIUser host) + => apiRequestHandler.AddServerSideRoom(room, host); + + public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager) + => apiRequestHandler.HandleRequest(request, localUser, beatmapManager); + + #endregion } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs deleted file mode 100644 index b998a638e5..0000000000 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Screens.OnlinePlay.Multiplayer; -using osu.Game.Tests.Visual.OnlinePlay; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - /// - /// A for use in multiplayer test scenes. - /// Should generally not be used by itself outside of a . - /// - public partial class TestMultiplayerRoomManager : MultiplayerRoomManager - { - private readonly TestRoomRequestsHandler requestsHandler; - - public TestMultiplayerRoomManager(TestRoomRequestsHandler requestsHandler) - { - this.requestsHandler = requestsHandler; - } - - public IReadOnlyList ServerSideRooms => requestsHandler.ServerSideRooms; - - public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - => base.CreateRoom(room, r => onSuccess?.Invoke(r), onError); - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - => base.JoinRoom(room, password, r => onSuccess?.Invoke(r), onError); - - /// - /// Adds a room to a local "server-side" list that's returned when a is fired. - /// - /// The room. - /// The host. - public void AddServerSideRoom(Room room, APIUser host) => requestsHandler.AddServerSideRoom(room, host); - } -} diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs index 8ddc5325db..60730ee9a4 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; @@ -13,16 +12,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public interface IOnlinePlayTestSceneDependencies { - /// - /// The cached . - /// - Bindable SelectedRoom { get; } - - /// - /// The cached - /// - IRoomManager RoomManager { get; } - /// /// The cached . /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 3f6c175fbd..ce8df36590 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -3,14 +3,15 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay; namespace osu.Game.Tests.Visual.OnlinePlay @@ -20,8 +21,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public abstract partial class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom => OnlinePlayDependencies.SelectedRoom; - public IRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public OngoingOperationTracker OngoingOperationTracker => OnlinePlayDependencies.OngoingOperationTracker; public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker => OnlinePlayDependencies.AvailabilityTracker; public TestUserLookupCache UserLookupCache => OnlinePlayDependencies.UserLookupCache; @@ -34,9 +33,13 @@ namespace osu.Game.Tests.Visual.OnlinePlay protected override Container Content => content; + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + private readonly Container content; private readonly Container drawableDependenciesContainer; private DelegatedDependencyContainer dependencies = null!; + private int currentRoomId; protected OnlinePlayTestScene() { @@ -93,6 +96,31 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// protected virtual OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new OnlinePlayTestSceneDependencies(); + protected Room[] GenerateRooms(int count, RulesetInfo? ruleset = null, bool withPassword = false, bool withSpotlightRooms = false) + { + Room[] rooms = new Room[count]; + + // Can't reference Osu ruleset project here. + ruleset ??= rulesets.GetRuleset(0)!; + + for (int i = 0; i < count; i++) + { + rooms[i] = new Room + { + RoomID = currentRoomId++, + Name = $@"Room {currentRoomId}", + Host = new APIUser { Username = @"Host" }, + Duration = TimeSpan.FromSeconds(10), + Category = withSpotlightRooms && i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal, + Password = withPassword ? @"password" : null, + PlaylistItemStats = new Room.RoomPlaylistItemStats { RulesetIDs = [ruleset.OnlineID] }, + Playlist = [new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) { RulesetID = ruleset.OnlineID }] + }; + } + + return rooms; + } + /// /// A providing a mutable lookup source for online play dependencies. /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs index e2670c9ad8..9537c7958c 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Database; using osu.Game.Online.Rooms; @@ -18,8 +17,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public class OnlinePlayTestSceneDependencies : IReadOnlyDependencyContainer, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom { get; } - public IRoomManager RoomManager { get; } public OngoingOperationTracker OngoingOperationTracker { get; } public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } public TestRoomRequestsHandler RequestsHandler { get; } @@ -36,19 +33,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay public OnlinePlayTestSceneDependencies() { - SelectedRoom = new Bindable(); RequestsHandler = new TestRoomRequestsHandler(); OngoingOperationTracker = new OngoingOperationTracker(); AvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); - RoomManager = CreateRoomManager(); UserLookupCache = new TestUserLookupCache(); BeatmapLookupCache = new BeatmapLookupCache(); dependencies = new DependencyContainer(); CacheAs(RequestsHandler); - CacheAs(SelectedRoom); - CacheAs(RoomManager); CacheAs(OngoingOperationTracker); CacheAs(AvailabilityTracker); CacheAs(new OverlayColourProvider(OverlayColourScheme.Plum)); @@ -80,7 +73,5 @@ namespace osu.Game.Tests.Visual.OnlinePlay if (instance is Drawable drawable) drawableComponents.Add(drawable); } - - protected virtual IRoomManager CreateRoomManager() => new TestRoomManager(); } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs deleted file mode 100644 index b1e3eafacc..0000000000 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay.Components; - -namespace osu.Game.Tests.Visual.OnlinePlay -{ - /// - /// A very simple for use in online play test scenes. - /// - public partial class TestRoomManager : RoomManager - { - public Action? JoinRoomRequested; - - private int currentRoomId; - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - JoinRoomRequested?.Invoke(room, password); - base.JoinRoom(room, password, onSuccess, onError); - } - - public void AddRooms(int count, RulesetInfo? ruleset = null, bool withPassword = false, bool withSpotlightRooms = false) - { - for (int i = 0; i < count; i++) - { - AddRoom(new Room - { - Name = $@"Room {currentRoomId}", - Host = new APIUser { Username = @"Host" }, - Duration = TimeSpan.FromSeconds(10), - Category = withSpotlightRooms && i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal, - Password = withPassword ? @"password" : null, - PlaylistItemStats = ruleset == null - ? null - : new Room.RoomPlaylistItemStats { RulesetIDs = [ruleset.OnlineID] }, - Playlist = ruleset == null - ? Array.Empty() - : [new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) { RulesetID = ruleset.OnlineID }] - }); - } - } - - public void AddRoom(Room room) - { - room.RoomID = -currentRoomId; - CreateRoom(room); - currentRoomId++; - } - } -} diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index c9149bda22..46c1251d42 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; @@ -15,7 +16,6 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Beatmaps; using osu.Game.Utils; @@ -28,7 +28,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay public class TestRoomRequestsHandler { public IReadOnlyList ServerSideRooms => serverSideRooms; - private readonly List serverSideRooms = new List(); private int currentRoomId = 1; @@ -36,8 +35,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay private int currentScoreId = 1; /// - /// Handles an API request, while also updating the local state to match - /// how the server would eventually respond and update an . + /// Handles an API request, while also updating the local state to match how the server would eventually respond. /// /// The API request to handle. /// The local user to store in responses where required. @@ -128,6 +126,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay MaxCombo = 100, TotalScore = 200000, User = new APIUser { Username = "worst user" }, + Mods = [new APIMod { Acronym = @"TD" }], Statistics = new Dictionary() }, }, @@ -221,7 +220,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay : new APIUser { Id = id, - Username = $"User {id}" + Username = $"User {id}", + Team = RNG.NextBool() + ? new APITeam + { + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/flag/1/wanglogo.jpg", + } + : null, }) .Where(u => u != null).ToList(), }); diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index aa8aff3adc..a644936a16 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Visual base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); base.Content.Add(new MouseMovementInterceptor { - MouseMoved = updatePlacementTimeAndPosition, + MouseMoved = UpdatePlacementTimeAndPosition, }); } @@ -51,7 +51,9 @@ namespace osu.Game.Tests.Visual protected virtual IBeatmap GetPlayableBeatmap() { - var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + var rulesetInfo = CreateRuleset()!.RulesetInfo; + var playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo); + playable.BeatmapInfo.Ruleset = rulesetInfo; playable.Difficulty.CircleSize = 2; return playable; } @@ -93,13 +95,10 @@ namespace osu.Game.Tests.Visual if (CurrentBlueprint.PlacementActive == PlacementBlueprint.PlacementState.Finished) ResetPlacement(); - updatePlacementTimeAndPosition(); + UpdatePlacementTimeAndPosition(); } - private void updatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); - - protected virtual SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint) => - new SnapResult(InputManager.CurrentState.Mouse.Position, null); + protected virtual void UpdatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(InputManager.CurrentState.Mouse.Position, 0); public override void Add(Drawable drawable) { @@ -108,7 +107,7 @@ namespace osu.Game.Tests.Visual if (drawable is HitObjectPlacementBlueprint blueprint) { blueprint.Show(); - blueprint.UpdateTimeAndPosition(SnapForBlueprint(blueprint)); + UpdatePlacementTimeAndPosition(); } } diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index a311531088..5d33afd288 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -78,12 +78,12 @@ namespace osu.Game.Tests.Visual.Spectator /// The spectator state to end play with. public void SendEndPlay(int userId, SpectatedUserState state = SpectatedUserState.Quit) { - if (!userBeatmapDictionary.TryGetValue(userId, out int value)) + if (!userBeatmapDictionary.TryGetValue(userId, out int beatmapId)) return; ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState { - BeatmapID = value, + BeatmapID = beatmapId, RulesetID = 0, Mods = userModsDictionary[userId], State = state diff --git a/osu.Game/Users/Drawables/ClickableUsername.cs b/osu.Game/Users/Drawables/ClickableUsername.cs new file mode 100644 index 0000000000..74782ed6ed --- /dev/null +++ b/osu.Game/Users/Drawables/ClickableUsername.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + internal partial class ClickableUsername : OsuHoverContainer, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new ClickableAvatar.NoCardTooltip(); + + public APIUser? TooltipContent { get; } + + private readonly APIUser user; + + [Resolved] + private OsuGame? game { get; set; } + + public ClickableUsername(APIUser? user) + { + TooltipContent = this.user = user ?? new GuestUser(); + + AutoSizeAxes = Axes.Both; + + Child = new OsuSpriteText + { + Text = user!.Username, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }; + + if (user.Id != APIUser.SYSTEM_USER_ID) + Action = openProfile; + } + + private void openProfile() + { + if (user.Id > 1 || !string.IsNullOrEmpty(user.Username)) + game?.ShowUser(user); + } + } +} diff --git a/osu.Game/Users/Drawables/UpdateableTeamFlag.cs b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs new file mode 100644 index 0000000000..2fcec66aa7 --- /dev/null +++ b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + /// + /// A team logo which can update to a new team when needed. + /// + public partial class UpdateableTeamFlag : ModelBackedDrawable + { + public APITeam? Team + { + get => Model; + set + { + Model = value; + Invalidate(Invalidation.Presence); + } + } + + protected override double LoadDelay => 200; + + public UpdateableTeamFlag(APITeam? team = null) + { + Team = team; + + Masking = true; + } + + protected override Drawable? CreateDrawable(APITeam? team) + { + if (team == null) + return Empty(); + + return new TeamFlag(team) { RelativeSizeAxes = Axes.Both }; + } + + // Generally we just want team flags to disappear if the user doesn't have one. + // This also handles fill flow cases and avoids spacing being added for non-displaying flags. + public override bool IsPresent => base.IsPresent && Team != null; + + protected override void Update() + { + base.Update(); + + CornerRadius = DrawHeight / 8; + } + + [LongRunningLoad] + public partial class TeamFlag : CompositeDrawable, IHasTooltip + { + private readonly APITeam team; + + public LocalisableString TooltipText { get; } + + public TeamFlag(APITeam team) + { + this.team = team; + TooltipText = team.Name; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + InternalChildren = new Drawable[] + { + new HoverClickSounds(), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.FromHex("333"), + }, + new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(team.FlagUrl), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit, + } + }; + } + } + } +} diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs index e33fb7a44e..0185165b36 100644 --- a/osu.Game/Users/ExtendedUserPanel.cs +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,43 +13,51 @@ using osu.Game.Users.Drawables; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; namespace osu.Game.Users { public abstract partial class ExtendedUserPanel : UserPanel { - public readonly Bindable Status = new Bindable(); + protected TextFlowContainer LastVisitMessage { get; private set; } = null!; - public readonly IBindable Activity = new Bindable(); + private StatusIcon statusIcon = null!; + private StatusText statusMessage = null!; - protected TextFlowContainer LastVisitMessage { get; private set; } + [Resolved] + private MetadataClient? metadata { get; set; } - private StatusIcon statusIcon; - private StatusText statusMessage; + private UserStatus? lastStatus; + private UserActivity? lastActivity; + private DateTimeOffset? lastVisit; protected ExtendedUserPanel(APIUser user) : base(user) { + lastVisit = user.LastVisit; } [BackgroundDependencyLoader] private void load() { BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter; - - Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); - Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); } protected override void LoadComplete() { base.LoadComplete(); - Status.TriggerChange(); + updatePresence(); // Colour should be applied immediately on first load. statusIcon.FinishTransforms(); } + protected override void Update() + { + base.Update(); + updatePresence(); + } + protected Container CreateStatusIcon() => statusIcon = new StatusIcon(); protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) @@ -70,15 +76,6 @@ namespace osu.Game.Users text.Origin = alignment; text.AutoSizeAxes = Axes.Both; text.Alpha = 0; - - if (User.LastVisit.HasValue) - { - text.AddText(@"Last seen "); - text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) - { - Shadow = false - }); - } })); statusContainer.Add(statusMessage = new StatusText @@ -91,37 +88,48 @@ namespace osu.Game.Users return statusContainer; } - private void displayStatus(UserStatus? status, UserActivity activity = null) + private void updatePresence() { - if (status != null) + // TODO: we probably don't want to do this every frame. + UserPresence? presence = metadata?.GetPresence(User.OnlineID); + UserStatus status = presence?.Status ?? UserStatus.Offline; + UserActivity? activity = presence?.Activity; + + if (status == lastStatus && activity == lastActivity) + return; + + if (status == UserStatus.Offline && lastVisit != null) { - LastVisitMessage.FadeTo(status == UserStatus.Offline && User.LastVisit.HasValue ? 1 : 0); - - // Set status message based on activity (if we have one) and status is not offline - if (activity != null && status != UserStatus.Offline) + LastVisitMessage.FadeTo(1); + LastVisitMessage.Clear(); + LastVisitMessage.AddText(@"Last seen "); + LastVisitMessage.AddText(new DrawableDate(lastVisit.Value, italic: false) { - statusMessage.Text = activity.GetStatus(); - statusMessage.TooltipText = activity.GetDetails(); - statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); - return; - } + Shadow = false + }); + } + else + LastVisitMessage.FadeTo(0); - // Otherwise use only status + // Set status message based on activity (if we have one) and status is not offline + if (activity != null && status != UserStatus.Offline) + { + statusMessage.Text = activity.GetStatus(); + statusMessage.TooltipText = activity.GetDetails() ?? string.Empty; + statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); + } + + // Otherwise use only status + else + { statusMessage.Text = status.GetLocalisableDescription(); statusMessage.TooltipText = string.Empty; - statusIcon.FadeColour(status.Value.GetAppropriateColour(Colours), 500, Easing.OutQuint); - - return; + statusIcon.FadeColour(status.GetAppropriateColour(Colours), 500, Easing.OutQuint); } - // Fallback to web status if local one is null - if (User.IsOnline) - { - Status.Value = UserStatus.Online; - return; - } - - Status.Value = UserStatus.Offline; + lastStatus = status; + lastActivity = activity; + lastVisit = status != UserStatus.Offline ? DateTimeOffset.Now : lastVisit; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index a8e0fc9030..16b30546de 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -34,6 +34,8 @@ namespace osu.Game.Users [Union(41, typeof(EditingBeatmap))] [Union(42, typeof(ModdingBeatmap))] [Union(43, typeof(TestingBeatmap))] + [Union(51, typeof(InDailyChallengeLobby))] + [Union(52, typeof(PlayingDailyChallenge))] public abstract class UserActivity { public abstract string GetStatus(bool hideIdentifiableInformation = false); @@ -54,6 +56,11 @@ namespace osu.Game.Users } [MessagePackObject] + [Union(12, typeof(InSoloGame))] + [Union(23, typeof(InMultiplayerGame))] + [Union(24, typeof(SpectatingMultiplayerGame))] + [Union(31, typeof(InPlaylistGame))] + [Union(52, typeof(PlayingDailyChallenge))] public abstract class InGame : UserActivity { [Key(0)] @@ -240,7 +247,7 @@ namespace osu.Game.Users [SerializationConstructor] public SpectatingMultiplayerGame() { } - public override string GetStatus(bool hideIdentifiableInformation = false) => $"Watching others {base.GetStatus(hideIdentifiableInformation).ToLowerInvariant()}"; + public override string GetStatus(bool hideIdentifiableInformation = false) => @"Spectating a multiplayer game"; } [MessagePackObject] @@ -273,5 +280,30 @@ namespace osu.Game.Users ? null : RoomName; } + + [MessagePackObject] + public class InDailyChallengeLobby : UserActivity + { + [SerializationConstructor] + public InDailyChallengeLobby() { } + + public override string GetStatus(bool hideIdentifiableInformation = false) => @"In daily challenge lobby"; + } + + [MessagePackObject] + public class PlayingDailyChallenge : InGame + { + public PlayingDailyChallenge(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + + [SerializationConstructor] + public PlayingDailyChallenge() + { + } + + public override string GetStatus(bool hideIdentifiableInformation = false) => @$"{RulesetPlayingVerb} in daily challenge"; + } } } diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs index fce543415d..f62c9ab4e7 100644 --- a/osu.Game/Users/UserGridPanel.cs +++ b/osu.Game/Users/UserGridPanel.cs @@ -82,9 +82,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 0d3ea52611..1010234e1f 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -22,6 +22,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osu.Game.Localisation; +using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; using osu.Game.Screens; using osu.Game.Screens.Play; @@ -76,6 +77,9 @@ namespace osu.Game.Users [Resolved] private MultiplayerClient? multiplayerClient { get; set; } + [Resolved] + private MetadataClient? metadataClient { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -130,6 +134,11 @@ namespace osu.Game.Users Action = Action, }; + protected Drawable CreateTeamLogo() => new UpdateableTeamFlag(User.Team) + { + Size = new Vector2(52, 26), + }; + public MenuItem[] ContextMenuItems { get @@ -148,20 +157,28 @@ namespace osu.Game.Users chatOverlay?.Show(); })); - if (User.IsOnline) + if (isUserOnline()) { items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => { - performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); + if (isUserOnline()) + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); })); - if (multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true) + if (canInviteUser()) { - items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(User.Id))); + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => + { + if (canInviteUser()) + multiplayerClient!.InvitePlayer(User.Id); + })); } } return items.ToArray(); + + bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; + bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; } } diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs index 5e3ae172be..ff8adf055c 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -147,9 +147,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 918a1b6968..687dd52594 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -4,9 +4,14 @@ #nullable disable using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using JetBrains.Annotations; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Utils; @@ -74,6 +79,10 @@ namespace osu.Game.Users [JsonProperty(@"grade_counts")] public Grades GradesCount; + [JsonProperty(@"variants")] + [CanBeNull] + public List Variants; + public struct Grades { [JsonProperty(@"ssh")] @@ -118,5 +127,35 @@ namespace osu.Game.Users } } } + + public enum RulesetVariant + { + [EnumMember(Value = "4k")] + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.VariantMania4k))] + FourKey, + + [EnumMember(Value = "7k")] + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.VariantMania7k))] + SevenKey + } + + public class Variant + { + [JsonProperty("country_rank")] + public int? CountryRank; + + [JsonProperty("global_rank")] + public int? GlobalRank; + + [JsonProperty("mode")] + public string Mode; + + [JsonProperty("pp")] + public decimal PP; + + [JsonProperty("variant")] + [JsonConverter(typeof(StringEnumConverter))] + public RulesetVariant VariantType; + } } } diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index cccad3711c..e93a494b65 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -32,6 +32,12 @@ namespace osu.Game.Utils /// The rank/position to be formatted. public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 100_000 ? 1 : 0); + /// + /// Formats the supplied star rating in a consistent, simplified way. + /// + /// The star rating to be formatted. + public static LocalisableString FormatStarRating(this double starRating) => starRating.ToLocalisableString("0.00"); + /// /// Finds the number of digits after the decimal. /// diff --git a/osu.Game/Utils/MobileUtils.cs b/osu.Game/Utils/MobileUtils.cs new file mode 100644 index 0000000000..6e59efb71c --- /dev/null +++ b/osu.Game/Utils/MobileUtils.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Utils +{ + public static class MobileUtils + { + /// + /// Determines the correct state which a mobile device should be put into for the given information. + /// + /// Information about whether the user is currently playing. + /// The current screen which the user is at. + /// Whether the user is playing on a mobile tablet device instead of a phone. + public static Orientation GetOrientation(ILocalUserPlayInfo userPlayInfo, IOsuScreen currentScreen, bool isTablet) + { + bool lockCurrentOrientation = userPlayInfo.PlayingState.Value == LocalUserPlayingState.Playing; + bool lockToPortraitOnPhone = currentScreen.RequiresPortraitOrientation; + + if (lockToPortraitOnPhone && !isTablet) + return Orientation.Portrait; + + if (lockCurrentOrientation) + return Orientation.Locked; + + return Orientation.Default; + } + + public enum Orientation + { + /// + /// Lock the game orientation. + /// + Locked, + + /// + /// Lock the game to portrait orientation (does not include upside-down portrait). + /// + Portrait, + + /// + /// Use the application's default settings. + /// + Default, + } + } +} diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index f901f15388..ac24bf2130 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -286,10 +287,51 @@ namespace osu.Game.Utils { double rate = 1; + // TODO: This doesn't consider mods which apply variable rates, yet. foreach (var mod in mods.OfType()) rate = mod.ApplyToRate(0, rate); return rate; } + + /// + /// Determines whether a mod can be applied to playlist items in the given match type. + /// + /// The mod to test. + /// The match type. + public static bool IsValidModForMatchType(Mod mod, MatchType type) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + switch (type) + { + case MatchType.Playlists: + return true; + + default: + return mod.ValidForMultiplayer; + } + } + + /// + /// Determines whether a mod can be applied as a free mod to playlist items in the given match type. + /// + /// The mod to test. + /// The match type. + public static bool IsValidFreeModForMatchType(Mod mod, MatchType type) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + switch (type) + { + case MatchType.Playlists: + return true; + + default: + return mod.ValidForMultiplayerAsFreeMod; + } + } } } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 8d3e5fb834..2172ea895e 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -36,11 +37,11 @@ namespace osu.Game.Utils private readonly OsuGame game; - public SentryLogger(OsuGame game) + public SentryLogger(OsuGame game, Storage? storage = null) { this.game = game; - if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteRootUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) + if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) return; sentrySession = SentrySdk.Init(options => @@ -49,6 +50,7 @@ namespace osu.Game.Utils options.AutoSessionTracking = true; options.IsEnvironmentUser = false; options.IsGlobalModeEnabled = true; + options.CacheDirectoryPath = storage?.GetFullPath(string.Empty); // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}"; }); diff --git a/osu.Game/Utils/SupportedExtensions.cs b/osu.Game/Utils/SupportedExtensions.cs new file mode 100644 index 0000000000..ec1538a041 --- /dev/null +++ b/osu.Game/Utils/SupportedExtensions.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Utils +{ + public static class SupportedExtensions + { + public static readonly string[] VIDEO_EXTENSIONS = [@".mp4", @".mov", @".avi", @".flv", @".mpg", @".wmv", @".m4v"]; + public static readonly string[] AUDIO_EXTENSIONS = [@".mp3", @".ogg", @".wav"]; + public static readonly string[] IMAGE_EXTENSIONS = [@".jpg", @".jpeg", @".png"]; + + public static readonly string[] ALL_EXTENSIONS = + [ + ..VIDEO_EXTENSIONS, + ..AUDIO_EXTENSIONS, + ..IMAGE_EXTENSIONS + ]; + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index bb951c487b..e9d7bacc64 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,26 +20,26 @@ - + - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + diff --git a/osu.iOS.props b/osu.iOS.props index 80e695e5d1..2fa83c3ab0 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs new file mode 100644 index 0000000000..5d309f2fc1 --- /dev/null +++ b/osu.iOS/AppDelegate.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Foundation; +using osu.Framework.iOS; +using UIKit; + +namespace osu.iOS +{ + [Register("AppDelegate")] + public class AppDelegate : GameApplicationDelegate + { + private UIInterfaceOrientationMask? defaultOrientationsMask; + private UIInterfaceOrientationMask? orientations; + + /// + /// The current orientation the game is displayed in. + /// + public UIInterfaceOrientation CurrentOrientation => Host.Window.UIWindow.WindowScene!.InterfaceOrientation; + + /// + /// Controls the orientations allowed for the device to rotate to, overriding the default allowed orientations. + /// + public UIInterfaceOrientationMask? Orientations + { + get => orientations; + set + { + if (orientations == value) + return; + + orientations = value; + + if (OperatingSystem.IsIOSVersionAtLeast(16)) + Host.Window.ViewController.SetNeedsUpdateOfSupportedInterfaceOrientations(); + else + UIViewController.AttemptRotationToDeviceOrientation(); + } + } + + protected override Framework.Game CreateGame() => new OsuGameIOS(this); + + public override UIInterfaceOrientationMask GetSupportedInterfaceOrientations(UIApplication application, UIWindow forWindow) + { + if (orientations != null) + return orientations.Value; + + if (defaultOrientationsMask == null) + { + defaultOrientationsMask = 0; + var defaultOrientations = (NSArray)NSBundle.MainBundle.ObjectForInfoDictionary("UISupportedInterfaceOrientations"); + + foreach (var value in defaultOrientations.ToArray()) + defaultOrientationsMask |= Enum.Parse(value.ToString().Replace("UIInterfaceOrientation", string.Empty)); + } + + return defaultOrientationsMask.Value; + } + } +} diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 1330e29bc1..120e8caecc 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -34,9 +34,11 @@ CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription - We don't really use the camera. + We don't use the camera. NSMicrophoneUsageDescription - We don't really use the microphone. + We don't use the microphone. + NSBluetoothAlwaysUsageDescription + We don't use Bluetooth. UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeRight @@ -68,6 +70,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! replay UTTypeIdentifier sh.ppy.osu.osr UTTypeTagSpecification @@ -81,6 +85,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! skin UTTypeIdentifier sh.ppy.osu.osk UTTypeTagSpecification @@ -94,6 +100,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! beatmap UTTypeIdentifier sh.ppy.osu.osz UTTypeTagSpecification @@ -107,6 +115,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! beatmap UTTypeIdentifier sh.ppy.osu.olz UTTypeTagSpecification @@ -143,7 +153,15 @@ Editor + ITSAppUsesNonExemptEncryption + LSApplicationCategoryType public.app-category.music-games + LSSupportsOpeningDocumentsInPlace + + + GCSupportsGameMode + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 2a4f9b87ac..96b8fb9804 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -5,20 +5,72 @@ using System; using Foundation; using Microsoft.Maui.Devices; using osu.Framework.Graphics; +using osu.Framework.iOS; +using osu.Framework.Platform; using osu.Game; +using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; +using osuTK; +using UIKit; namespace osu.iOS { public partial class OsuGameIOS : OsuGame { + private readonly AppDelegate appDelegate; public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); + public override bool HideUnlicensedContent => true; + + public override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth); + + public OsuGameIOS(AppDelegate appDelegate) + { + this.appDelegate = appDelegate; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + UserPlayingState.BindValueChanged(_ => updateOrientation()); + } + + protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen) + { + base.ScreenChanged(current, newScreen); + + if (newScreen != null) + updateOrientation(); + } + + private void updateOrientation() => UIApplication.SharedApplication.InvokeOnMainThread(() => + { + bool iPad = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad; + var orientation = MobileUtils.GetOrientation(this, (IOsuScreen)ScreenStack.CurrentScreen, iPad); + + switch (orientation) + { + case MobileUtils.Orientation.Locked: + appDelegate.Orientations = (UIInterfaceOrientationMask)(1 << (int)appDelegate.CurrentOrientation); + break; + + case MobileUtils.Orientation.Portrait: + appDelegate.Orientations = UIInterfaceOrientationMask.Portrait; + break; + + case MobileUtils.Orientation.Default: + appDelegate.Orientations = null; + break; + } + }); + protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); protected override BatteryInfo CreateBatteryInfo() => new IOSBatteryInfo(); + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorageIOS((IOSGameHost)host, defaultStorage); + protected override Edges SafeAreaOverrideEdges => // iOS shows a home indicator at the bottom, and adds a safe area to account for this. // Because we have the home indicator (mostly) hidden we don't really care about drawing in this region. diff --git a/osu.iOS/OsuStorageIOS.cs b/osu.iOS/OsuStorageIOS.cs new file mode 100644 index 0000000000..f3a5eec737 --- /dev/null +++ b/osu.iOS/OsuStorageIOS.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.iOS; +using osu.Framework.Platform; +using osu.Game.IO; + +namespace osu.iOS +{ + public class OsuStorageIOS : OsuStorage + { + private readonly IOSGameHost host; + + public OsuStorageIOS(IOSGameHost host, Storage defaultStorage) + : base(host, defaultStorage) + { + this.host = host; + } + + public override Storage GetExportStorage() => new IOSStorage(Path.GetTempPath(), host); + } +} diff --git a/osu.iOS/Application.cs b/osu.iOS/Program.cs similarity index 69% rename from osu.iOS/Application.cs rename to osu.iOS/Program.cs index 74bd58acb8..fd24ecf419 100644 --- a/osu.iOS/Application.cs +++ b/osu.iOS/Program.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.iOS; +using UIKit; namespace osu.iOS { - public static class Application + public static class Program { public static void Main(string[] args) { - GameApplication.Main(new OsuGameIOS()); + UIApplication.Main(args, null, typeof(AppDelegate)); } } } diff --git a/osu.sln b/osu.sln index 829e43fc65..63da18c23e 100644 --- a/osu.sln +++ b/osu.sln @@ -56,11 +56,10 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{10DF8F12-50FD-45D8-8A38-17BA764BF54D}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - .globalconfig = .globalconfig Directory.Build.props = Directory.Build.props osu.Android.props = osu.Android.props osu.iOS.props = osu.iOS.props - CodeAnalysis\osu.ruleset = CodeAnalysis\osu.ruleset + global.json = global.json osu.sln.DotSettings = osu.sln.DotSettings osu.TestProject.props = osu.TestProject.props EndProjectSection @@ -95,6 +94,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{1743BF7C-E6AE-4A06-BAD9-166D62894303}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CodeAnalysis", "CodeAnalysis", "{FB156649-D457-4D1A-969C-D3A23FD31513}" + ProjectSection(SolutionItems) = preProject + CodeAnalysis\BannedSymbols.txt = CodeAnalysis\BannedSymbols.txt + CodeAnalysis\osu.globalconfig = CodeAnalysis\osu.globalconfig + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index ccd6db354b..5cac0024b7 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -170,7 +170,7 @@ WARNING HINT WARNING - WARNING + HINT WARNING ERROR WARNING @@ -840,6 +840,7 @@ See the LICENCE file in the repository root for full licence text. True True True + True True True True