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 a1c53ece03..2baa7ee0e0 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-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 683e9fd5e8..a2308e6dfc 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-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 b7a7fff18a..e839d2657c 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 683e9fd5e8..a2308e6dfc 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/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 25f4cff00e..1d43e118a3 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -26,7 +26,7 @@
-
+
diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
index 4719d54138..febe353b81 100644
--- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
+++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
@@ -7,9 +7,9 @@
-
+
-
+
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneScoring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneScoring.cs
new file mode 100644
index 0000000000..dfdde0a325
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneScoring.cs
@@ -0,0 +1,157 @@
+// 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.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Catch.Beatmaps;
+using osu.Game.Rulesets.Catch.Judgements;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.Scoring;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Tests.Visual.Gameplay;
+
+namespace osu.Game.Rulesets.Catch.Tests
+{
+ [TestFixture]
+ public partial class TestSceneScoring : ScoringTestScene
+ {
+ public TestSceneScoring()
+ : base(supportsNonPerfectJudgements: false)
+ {
+ }
+
+ private Bindable scoreMultiplier { get; } = new BindableDouble
+ {
+ Default = 4,
+ Value = 4
+ };
+
+ protected override IBeatmap CreateBeatmap(int maxCombo)
+ {
+ var beatmap = new CatchBeatmap();
+ for (int i = 0; i < maxCombo; ++i)
+ beatmap.HitObjects.Add(new Fruit());
+ return beatmap;
+ }
+
+ protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
+
+ protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
+
+ protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new CatchProcessorBasedScoringAlgorithm(beatmap, mode);
+
+ [Test]
+ public void TestBasicScenarios()
+ {
+ AddStep("set up score multiplier", () =>
+ {
+ scoreMultiplier.BindValueChanged(_ => Rerun());
+ });
+ AddStep("set max combo to 100", () => MaxCombo.Value = 100);
+ AddStep("set perfect score", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ });
+ AddStep("set score with misses", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
+ }
+
+ private const int base_great = 300;
+
+ private class ScoreV1 : IScoringAlgorithm
+ {
+ private int currentCombo;
+
+ public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
+
+ public void ApplyHit() => applyHitV1(base_great);
+
+ public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
+
+ public void ApplyMiss() => applyHitV1(0);
+
+ private void applyHitV1(int baseScore)
+ {
+ if (baseScore == 0)
+ {
+ currentCombo = 0;
+ return;
+ }
+
+ TotalScore += baseScore;
+
+ // combo multiplier
+ // ReSharper disable once PossibleLossOfFraction
+ TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * ScoreMultiplier.Value));
+
+ currentCombo++;
+ }
+
+ public long TotalScore { get; private set; }
+ }
+
+ private class ScoreV2 : IScoringAlgorithm
+ {
+ private int currentCombo;
+ private double comboPortion;
+
+ private readonly double comboPortionMax;
+
+ private const double combo_base = 4;
+ private const int combo_cap = 200;
+
+ public ScoreV2(int maxCombo)
+ {
+ for (int i = 0; i < maxCombo; i++)
+ ApplyHit();
+
+ comboPortionMax = comboPortion;
+
+ currentCombo = 0;
+ comboPortion = 0;
+ }
+
+ public void ApplyHit() => applyHitV2(base_great);
+
+ public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
+
+ private void applyHitV2(int baseScore)
+ {
+ comboPortion += baseScore * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(combo_cap, combo_base));
+ }
+
+ public void ApplyMiss()
+ {
+ currentCombo = 0;
+ }
+
+ public long TotalScore
+ => (int)Math.Round(1000000 * comboPortion / comboPortionMax); // vast simplification, as we're not doing ticks here.
+ }
+
+ private class CatchProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
+ {
+ public CatchProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
+ : base(beatmap, mode)
+ {
+ }
+
+ protected override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor();
+
+ protected override JudgementResult CreatePerfectJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Great };
+
+ protected override JudgementResult CreateNonPerfectJudgementResult() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
+
+ protected override JudgementResult CreateMissJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Miss };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
index 01922b2a96..c45c85833c 100644
--- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
+++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
@@ -1,9 +1,9 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneScoring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneScoring.cs
new file mode 100644
index 0000000000..ae3ea861ea
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneScoring.cs
@@ -0,0 +1,175 @@
+// 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.Game.Beatmaps;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Judgements;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.Scoring;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Tests.Visual.Gameplay;
+
+namespace osu.Game.Rulesets.Mania.Tests
+{
+ [TestFixture]
+ public partial class TestSceneScoring : ScoringTestScene
+ {
+ protected override IBeatmap CreateBeatmap(int maxCombo)
+ {
+ var beatmap = new ManiaBeatmap(new StageDefinition(5));
+ for (int i = 0; i < maxCombo; ++i)
+ beatmap.HitObjects.Add(new Note());
+ return beatmap;
+ }
+
+ protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1(MaxCombo.Value);
+ protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
+ protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new ManiaProcessorBasedScoringAlgorithm(beatmap, mode);
+
+ [Test]
+ public void TestBasicScenarios()
+ {
+ AddStep("set max combo to 100", () => MaxCombo.Value = 100);
+ AddStep("set perfect score", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ });
+ AddStep("set score with misses", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddStep("set score with misses and OKs", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+
+ NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ }
+
+ private class ScoreV1 : IScoringAlgorithm
+ {
+ private int currentCombo;
+ private double comboAddition = 100;
+ private double totalScoreDouble;
+ private readonly double scoreMultiplier;
+
+ public ScoreV1(int maxCombo)
+ {
+ scoreMultiplier = 500000d / maxCombo;
+ }
+
+ public void ApplyHit() => applyHitV1(320, add => add + 2, 32);
+ public void ApplyNonPerfect() => applyHitV1(100, add => add - 24, 8);
+ public void ApplyMiss() => applyHitV1(0, _ => -56, 0);
+
+ private void applyHitV1(int scoreIncrease, Func comboAdditionFunc, int delta)
+ {
+ comboAddition = comboAdditionFunc(comboAddition);
+ if (currentCombo != 0 && currentCombo % 384 == 0)
+ comboAddition = 100;
+ comboAddition = Math.Max(0, Math.Min(comboAddition, 100));
+ double scoreIncreaseD = Math.Sqrt(comboAddition) * delta * scoreMultiplier / 320;
+
+ TotalScore = (long)totalScoreDouble;
+
+ scoreIncreaseD += scoreIncrease * scoreMultiplier / 320;
+ scoreIncrease = (int)scoreIncreaseD;
+
+ TotalScore += scoreIncrease;
+ totalScoreDouble += scoreIncreaseD;
+
+ if (scoreIncrease > 0)
+ currentCombo++;
+ }
+
+ public long TotalScore { get; private set; }
+ }
+
+ private class ScoreV2 : IScoringAlgorithm
+ {
+ private int currentCombo;
+ private double comboPortion;
+ private double currentBaseScore;
+ private double maxBaseScore;
+ private int currentHits;
+
+ private readonly double comboPortionMax;
+ private readonly int maxCombo;
+
+ private const double combo_base = 4;
+
+ public ScoreV2(int maxCombo)
+ {
+ this.maxCombo = maxCombo;
+
+ for (int i = 0; i < this.maxCombo; i++)
+ ApplyHit();
+
+ comboPortionMax = comboPortion;
+
+ currentCombo = 0;
+ comboPortion = 0;
+ currentBaseScore = 0;
+ maxBaseScore = 0;
+ currentHits = 0;
+ }
+
+ public void ApplyHit() => applyHitV2(305, 300);
+ public void ApplyNonPerfect() => applyHitV2(100, 100);
+
+ private void applyHitV2(int hitValue, int baseHitValue)
+ {
+ maxBaseScore += 305;
+ currentBaseScore += hitValue;
+ comboPortion += baseHitValue * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(400, combo_base));
+
+ currentHits++;
+ }
+
+ public void ApplyMiss()
+ {
+ currentHits++;
+ maxBaseScore += 305;
+ currentCombo = 0;
+ }
+
+ public long TotalScore
+ {
+ get
+ {
+ float accuracy = (float)(currentBaseScore / maxBaseScore);
+
+ return (int)Math.Round
+ (
+ 200000 * comboPortion / comboPortionMax +
+ 800000 * Math.Pow(accuracy, 2 + 2 * accuracy) * ((double)currentHits / maxCombo)
+ );
+ }
+ }
+ }
+
+ private class ManiaProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
+ {
+ public ManiaProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
+ : base(beatmap, mode)
+ {
+ }
+
+ protected override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor();
+
+ protected override JudgementResult CreatePerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Perfect };
+
+ protected override JudgementResult CreateNonPerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Ok };
+
+ protected override JudgementResult CreateMissJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Miss };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
index 027bf60a0c..b991db408c 100644
--- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
+++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
@@ -1,9 +1,9 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs
new file mode 100644
index 0000000000..bb09328ab7
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs
@@ -0,0 +1,176 @@
+// 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.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Osu.Beatmaps;
+using osu.Game.Rulesets.Osu.Judgements;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Scoring;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Tests.Visual.Gameplay;
+
+namespace osu.Game.Rulesets.Osu.Tests
+{
+ [TestFixture]
+ public partial class TestSceneScoring : ScoringTestScene
+ {
+ private Bindable scoreMultiplier { get; } = new BindableDouble
+ {
+ Default = 4,
+ Value = 4
+ };
+
+ protected override IBeatmap CreateBeatmap(int maxCombo)
+ {
+ var beatmap = new OsuBeatmap();
+ for (int i = 0; i < maxCombo; i++)
+ beatmap.HitObjects.Add(new HitCircle());
+ return beatmap;
+ }
+
+ protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
+ protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
+ protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new OsuProcessorBasedScoringAlgorithm(beatmap, mode);
+
+ [Test]
+ public void TestBasicScenarios()
+ {
+ AddStep("set up score multiplier", () =>
+ {
+ scoreMultiplier.BindValueChanged(_ => Rerun());
+ });
+ AddStep("set max combo to 100", () => MaxCombo.Value = 100);
+ AddStep("set perfect score", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ });
+ AddStep("set score with misses", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddStep("set score with misses and OKs", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+
+ NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
+ }
+
+ private const int base_great = 300;
+ private const int base_ok = 100;
+
+ private class ScoreV1 : IScoringAlgorithm
+ {
+ private int currentCombo;
+
+ public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
+
+ public void ApplyHit() => applyHitV1(base_great);
+ public void ApplyNonPerfect() => applyHitV1(base_ok);
+ public void ApplyMiss() => applyHitV1(0);
+
+ private void applyHitV1(int baseScore)
+ {
+ if (baseScore == 0)
+ {
+ currentCombo = 0;
+ return;
+ }
+
+ TotalScore += baseScore;
+
+ // combo multiplier
+ // ReSharper disable once PossibleLossOfFraction
+ TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * ScoreMultiplier.Value));
+
+ currentCombo++;
+ }
+
+ public long TotalScore { get; private set; }
+ }
+
+ private class ScoreV2 : IScoringAlgorithm
+ {
+ private int currentCombo;
+ private double comboPortion;
+ private double currentBaseScore;
+ private double maxBaseScore;
+ private int currentHits;
+
+ private readonly double comboPortionMax;
+ private readonly int maxCombo;
+
+ public ScoreV2(int maxCombo)
+ {
+ this.maxCombo = maxCombo;
+
+ for (int i = 0; i < this.maxCombo; i++)
+ ApplyHit();
+
+ comboPortionMax = comboPortion;
+
+ currentCombo = 0;
+ comboPortion = 0;
+ currentBaseScore = 0;
+ maxBaseScore = 0;
+ currentHits = 0;
+ }
+
+ public void ApplyHit() => applyHitV2(base_great);
+ public void ApplyNonPerfect() => applyHitV2(base_ok);
+
+ private void applyHitV2(int baseScore)
+ {
+ maxBaseScore += base_great;
+ currentBaseScore += baseScore;
+ comboPortion += baseScore * (1 + ++currentCombo / 10.0);
+
+ currentHits++;
+ }
+
+ public void ApplyMiss()
+ {
+ currentHits++;
+ maxBaseScore += base_great;
+ currentCombo = 0;
+ }
+
+ public long TotalScore
+ {
+ get
+ {
+ double accuracy = currentBaseScore / maxBaseScore;
+
+ return (int)Math.Round
+ (
+ 700000 * comboPortion / comboPortionMax +
+ 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo)
+ );
+ }
+ }
+ }
+
+ private class OsuProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
+ {
+ public OsuProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
+ : base(beatmap, mode)
+ {
+ }
+
+ protected override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor();
+ protected override JudgementResult CreatePerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great };
+ protected override JudgementResult CreateNonPerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok };
+ protected override JudgementResult CreateMissJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
index 4ad78a3190..e005d7cac3 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
@@ -3,6 +3,7 @@
#nullable disable
+using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Game.Audio;
@@ -19,7 +20,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
-using osu.Game.Beatmaps.Legacy;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Judgements;
@@ -35,16 +35,24 @@ namespace osu.Game.Rulesets.Osu.Tests
{
private int depthIndex;
- private readonly BindableBool snakingIn = new BindableBool();
- private readonly BindableBool snakingOut = new BindableBool();
+ private readonly BindableBool snakingIn = new BindableBool(true);
+ private readonly BindableBool snakingOut = new BindableBool(true);
- [SetUpSteps]
- public void SetUpSteps()
+ private float progressToHit;
+
+ protected override void LoadComplete()
{
- AddToggleStep("toggle snaking", v =>
+ base.LoadComplete();
+
+ AddToggleStep("disable snaking", v =>
{
- snakingIn.Value = v;
- snakingOut.Value = v;
+ snakingIn.Value = !v;
+ snakingOut.Value = !v;
+ });
+
+ AddSliderStep("hit at", 0f, 1f, 0f, v =>
+ {
+ progressToHit = v;
});
}
@@ -56,6 +64,18 @@ namespace osu.Game.Rulesets.Osu.Tests
config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut);
}
+ protected override void Update()
+ {
+ base.Update();
+
+ foreach (var slider in this.ChildrenOfType())
+ {
+ double completionProgress = Math.Clamp((Time.Current - slider.HitObject.StartTime) / slider.HitObject.Duration, 0, 1);
+ if (completionProgress > progressToHit && !slider.IsHit)
+ slider.HeadCircle.HitArea.Hit();
+ }
+ }
+
[Test]
public void TestVariousSliders()
{
@@ -206,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Tests
StackHeight = 10
};
- return createDrawable(slider, 2, 2);
+ return createDrawable(slider, 2);
}
private Drawable testSimpleMedium(int repeats = 0) => createSlider(5, repeats: repeats);
@@ -229,6 +249,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
var slider = new Slider
{
+ SliderVelocityMultiplier = speedMultiplier,
StartTime = Time.Current + time_offset,
Position = new Vector2(0, -(distance / 2)),
Path = new SliderPath(PathType.PerfectCurve, new[]
@@ -240,7 +261,7 @@ namespace osu.Game.Rulesets.Osu.Tests
StackHeight = stackHeight
};
- return createDrawable(slider, circleSize, speedMultiplier);
+ return createDrawable(slider, circleSize);
}
private Drawable testPerfect(int repeats = 0)
@@ -258,7 +279,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
- return createDrawable(slider, 2, 3);
+ return createDrawable(slider, 2);
}
private Drawable testLinear(int repeats = 0) => createLinear(repeats);
@@ -281,7 +302,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
- return createDrawable(slider, 2, 3);
+ return createDrawable(slider, 2);
}
private Drawable testBezier(int repeats = 0) => createBezier(repeats);
@@ -303,7 +324,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
- return createDrawable(slider, 2, 3);
+ return createDrawable(slider, 2);
}
private Drawable testLinearOverlapping(int repeats = 0) => createOverlapping(repeats);
@@ -326,7 +347,7 @@ namespace osu.Game.Rulesets.Osu.Tests
RepeatCount = repeats,
};
- return createDrawable(slider, 2, 3);
+ return createDrawable(slider, 2);
}
private Drawable testCatmull(int repeats = 0) => createCatmull(repeats);
@@ -352,15 +373,12 @@ namespace osu.Game.Rulesets.Osu.Tests
NodeSamples = repeatSamples
};
- return createDrawable(slider, 3, 1);
+ return createDrawable(slider, 3);
}
- private Drawable createDrawable(Slider slider, float circleSize, double speedMultiplier)
+ private Drawable createDrawable(Slider slider, float circleSize)
{
- var cpi = new LegacyControlPointInfo();
- cpi.Add(0, new DifficultyControlPoint { SliderVelocity = speedMultiplier });
-
- slider.ApplyDefaults(cpi, new BeatmapDifficulty
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty
{
CircleSize = circleSize,
SliderTickRate = 3
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs
index 630049f408..13166c2b6b 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.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;
@@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public partial class TestSceneSliderSnaking : TestSceneOsuPlayer
{
[Resolved]
- private AudioManager audioManager { get; set; }
+ private AudioManager audioManager { get; set; } = null!;
protected override bool Autoplay => autoplay;
private bool autoplay;
@@ -41,12 +39,12 @@ namespace osu.Game.Rulesets.Osu.Tests
private readonly BindableBool snakingIn = new BindableBool();
private readonly BindableBool snakingOut = new BindableBool();
- private IBeatmap beatmap;
+ private IBeatmap beatmap = null!;
private const double duration_of_span = 3605;
private const double fade_in_modifier = -1200;
- protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
+ protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null)
=> new ClockBackedTestWorkingBeatmap(this.beatmap = beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
[BackgroundDependencyLoader]
@@ -57,15 +55,8 @@ namespace osu.Game.Rulesets.Osu.Tests
config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut);
}
- private Slider slider;
- private DrawableSlider drawableSlider;
-
- [SetUp]
- public void Setup() => Schedule(() =>
- {
- slider = null;
- drawableSlider = null;
- });
+ private Slider slider = null!;
+ private DrawableSlider? drawableSlider;
protected override bool HasCustomSteps => true;
@@ -135,9 +126,9 @@ namespace osu.Game.Rulesets.Osu.Tests
}
[Test]
- public void TestRepeatArrowDoesNotMoveWhenHit()
+ public void TestRepeatArrowDoesNotMove([Values] bool useAutoplay)
{
- AddStep("enable autoplay", () => autoplay = true);
+ AddStep($"set autoplay to {useAutoplay}", () => autoplay = useAutoplay);
setSnaking(true);
CreateTest();
// repeat might have a chance to update its position depending on where in the frame its hit,
@@ -145,21 +136,12 @@ namespace osu.Game.Rulesets.Osu.Tests
addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionAlmostSame);
}
- [Test]
- public void TestRepeatArrowMovesWhenNotHit()
- {
- AddStep("disable autoplay", () => autoplay = false);
- setSnaking(true);
- CreateTest();
- addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionDecreased);
- }
-
private void retrieveSlider(int index)
{
AddStep("retrieve slider at index", () => slider = (Slider)beatmap.HitObjects[index]);
addSeekStep(() => slider.StartTime);
AddUntilStep("retrieve drawable slider", () =>
- (drawableSlider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null);
+ (drawableSlider = (DrawableSlider?)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null);
}
private void addEnsureSnakingInSteps(Func startTime) => addCheckPositionChangeSteps(startTime, getSliderEnd, positionIncreased);
@@ -179,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private Func timeAtRepeat(Func startTime, int repeatIndex) => () => startTime() + 100 + duration_of_span * repeatIndex;
private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? getSliderStart : getSliderEnd;
- private List getSliderCurve() => ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve;
+ private List getSliderCurve() => ((PlaySliderBody)drawableSlider!.Body.Drawable).CurrentCurve;
private Vector2 getSliderStart() => getSliderCurve().First();
private Vector2 getSliderEnd() => getSliderCurve().Last();
diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
index 57900bffd7..ea033cda45 100644
--- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
+++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
@@ -1,10 +1,10 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
index 0148ec1987..8930b4ad70 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
@@ -25,9 +25,6 @@ namespace osu.Game.Rulesets.Osu.Mods
[SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")]
public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true);
- [SettingSource("No slider head movement", "Pins slider heads at their starting position, regardless of time.")]
- public Bindable NoSliderHeadMovement { get; } = new BindableBool(true);
-
[SettingSource("Apply classic note lock", "Applies note lock to the full hit window.")]
public Bindable ClassicNoteLock { get; } = new BindableBool(true);
@@ -71,7 +68,6 @@ namespace osu.Game.Rulesets.Osu.Mods
switch (obj)
{
case DrawableSliderHead head:
- head.TrackFollowCircle = !NoSliderHeadMovement.Value;
if (FadeHitCircleEarly.Value && !usingHiddenFading)
applyEarlyFading(head);
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
index 01174d4d61..1a6a0a9ecc 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
@@ -228,7 +228,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1);
Ball.UpdateProgress(completionProgress);
- SliderBody?.UpdateProgress(completionProgress);
+ SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0);
foreach (DrawableHitObject hitObject in NestedHitObjects)
{
@@ -317,7 +317,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
switch (state)
{
case ArmedState.Hit:
- if (SliderBody?.SnakingOut.Value == true)
+ if (HeadCircle.IsHit && SliderBody?.SnakingOut.Value == true)
Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear.
break;
}
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs
index 41f6a40c0a..2dea05da2f 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs
@@ -3,11 +3,9 @@
#nullable disable
-using System;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Bindables;
-using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
@@ -24,12 +22,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override bool DisplayResult => HitObject?.JudgeAsNormalHitCircle ?? base.DisplayResult;
- ///
- /// Makes this track the follow circle when the start time is reached.
- /// If false, this will be pinned to its initial position in the slider.
- ///
- public bool TrackFollowCircle = true;
-
private readonly IBindable pathVersion = new Bindable();
protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle;
@@ -64,23 +56,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
CheckHittable = (d, t, r) => DrawableSlider.CheckHittable?.Invoke(d, t, r) ?? ClickAction.Hit;
}
- protected override void Update()
- {
- base.Update();
-
- Debug.Assert(Slider != null);
- Debug.Assert(HitObject != null);
-
- if (TrackFollowCircle)
- {
- double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1);
-
- //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice.
- if (!IsHit)
- Position = Slider.CurvePositionAt(completionProgress);
- }
- }
-
protected override HitResult ResultFor(double timeOffset)
{
Debug.Assert(HitObject != null);
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs
index 539777dd6b..aa507cbaf0 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs
@@ -46,22 +46,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
BorderSize = skin.GetConfig(OsuSkinConfiguration.SliderBorderSize)?.Value ?? 1;
BorderColour = skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White;
-
- drawableObject.HitObjectApplied += onHitObjectApplied;
- }
-
- private void onHitObjectApplied(DrawableHitObject obj)
- {
- var drawableSlider = (DrawableSlider)obj;
- if (drawableSlider.HitObject == null)
- return;
-
- // When not tracking the follow circle, unbind from the config and forcefully disable snaking out - it looks better that way.
- if (!drawableSlider.HeadCircle.TrackFollowCircle)
- {
- SnakingOut.UnbindFrom(configSnakingOut);
- SnakingOut.Value = false;
- }
}
protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) =>
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneScoring.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneScoring.cs
new file mode 100644
index 0000000000..e065070822
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneScoring.cs
@@ -0,0 +1,185 @@
+// 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.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.Taiko.Beatmaps;
+using osu.Game.Rulesets.Taiko.Judgements;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.Taiko.Scoring;
+using osu.Game.Tests.Visual.Gameplay;
+
+namespace osu.Game.Rulesets.Taiko.Tests
+{
+ [TestFixture]
+ public partial class TestSceneScoring : ScoringTestScene
+ {
+ private Bindable scoreMultiplier { get; } = new BindableDouble
+ {
+ Default = 4,
+ Value = 4
+ };
+
+ protected override IBeatmap CreateBeatmap(int maxCombo)
+ {
+ var beatmap = new TaikoBeatmap();
+ for (int i = 0; i < maxCombo; ++i)
+ beatmap.HitObjects.Add(new Hit());
+ return beatmap;
+ }
+
+ protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
+ protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
+
+ protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new TaikoProcessorBasedScoringAlgorithm(beatmap, mode);
+
+ [Test]
+ public void TestBasicScenarios()
+ {
+ AddStep("set up score multiplier", () =>
+ {
+ scoreMultiplier.BindValueChanged(_ => Rerun());
+ });
+ AddStep("set max combo to 100", () => MaxCombo.Value = 100);
+ AddStep("set perfect score", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ });
+ AddStep("set score with misses", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddStep("set score with misses and OKs", () =>
+ {
+ NonPerfectLocations.Clear();
+ MissLocations.Clear();
+
+ NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
+ MissLocations.AddRange(new[] { 24d, 49 });
+ });
+ AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
+ }
+
+ private const int base_great = 300;
+ private const int base_ok = 150;
+
+ private class ScoreV1 : IScoringAlgorithm
+ {
+ private int currentCombo;
+
+ public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
+
+ public void ApplyHit() => applyHitV1(base_great);
+
+ public void ApplyNonPerfect() => applyHitV1(base_ok);
+
+ public void ApplyMiss() => applyHitV1(0);
+
+ private void applyHitV1(int baseScore)
+ {
+ if (baseScore == 0)
+ {
+ currentCombo = 0;
+ return;
+ }
+
+ TotalScore += baseScore;
+
+ // combo multiplier
+ // ReSharper disable once PossibleLossOfFraction
+ TotalScore += (int)((baseScore / 35) * 2 * (ScoreMultiplier.Value + 1)) * (Math.Min(100, currentCombo) / 10);
+
+ currentCombo++;
+ }
+
+ public long TotalScore { get; private set; }
+ }
+
+ private class ScoreV2 : IScoringAlgorithm
+ {
+ private int currentCombo;
+ private double comboPortion;
+ private double currentBaseScore;
+ private double maxBaseScore;
+ private int currentHits;
+
+ private readonly double comboPortionMax;
+ private readonly int maxCombo;
+
+ private const double combo_base = 4;
+
+ public ScoreV2(int maxCombo)
+ {
+ this.maxCombo = maxCombo;
+
+ for (int i = 0; i < this.maxCombo; i++)
+ ApplyHit();
+
+ comboPortionMax = comboPortion;
+
+ currentCombo = 0;
+ comboPortion = 0;
+ currentBaseScore = 0;
+ maxBaseScore = 0;
+ currentHits = 0;
+ }
+
+ public void ApplyHit() => applyHitV2(base_great);
+
+ public void ApplyNonPerfect() => applyHitV2(base_ok);
+
+ private void applyHitV2(int baseScore)
+ {
+ maxBaseScore += base_great;
+ currentBaseScore += baseScore;
+
+ currentHits++;
+
+ // `base_great` is INTENTIONALLY used above here instead of `baseScore`
+ // see `BaseHitValue` override in `ScoreChangeTaiko` on stable
+ comboPortion += base_great * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(400, combo_base));
+ }
+
+ public void ApplyMiss()
+ {
+ currentHits++;
+ maxBaseScore += base_great;
+ currentCombo = 0;
+ }
+
+ public long TotalScore
+ {
+ get
+ {
+ double accuracy = currentBaseScore / maxBaseScore;
+
+ return (int)Math.Round
+ (
+ 250000 * comboPortion / comboPortionMax +
+ 750000 * Math.Pow(accuracy, 3.6) * ((double)currentHits / maxCombo)
+ );
+ }
+ }
+ }
+
+ private class TaikoProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
+ {
+ public TaikoProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
+ : base(beatmap, mode)
+ {
+ }
+
+ protected override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor();
+ protected override JudgementResult CreatePerfectJudgementResult() => new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great };
+ protected override JudgementResult CreateNonPerfectJudgementResult() => new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok };
+ protected override JudgementResult CreateMissJudgementResult() => new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
index 0c39ad988b..48465bb119 100644
--- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
+++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
@@ -1,9 +1,9 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
index be53a3e18a..3d2de2914a 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
@@ -621,6 +621,38 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
+ [Test]
+ public void TestInvalidBankDefaultsToNormal()
+ {
+ var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };
+
+ using (var resStream = TestResources.OpenResource("invalid-bank.osu"))
+ using (var stream = new LineBufferedReader(resStream))
+ {
+ var hitObjects = decoder.Decode(stream).HitObjects;
+
+ assertObjectHasBanks(hitObjects[0], HitSampleInfo.BANK_DRUM);
+ assertObjectHasBanks(hitObjects[1], HitSampleInfo.BANK_NORMAL);
+ assertObjectHasBanks(hitObjects[2], HitSampleInfo.BANK_SOFT);
+ assertObjectHasBanks(hitObjects[3], HitSampleInfo.BANK_DRUM);
+ assertObjectHasBanks(hitObjects[4], HitSampleInfo.BANK_NORMAL);
+
+ assertObjectHasBanks(hitObjects[5], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_DRUM);
+ assertObjectHasBanks(hitObjects[6], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_NORMAL);
+ assertObjectHasBanks(hitObjects[7], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_SOFT);
+ assertObjectHasBanks(hitObjects[8], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_DRUM);
+ assertObjectHasBanks(hitObjects[9], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_NORMAL);
+ }
+
+ void assertObjectHasBanks(HitObject hitObject, string normalBank, string? additionsBank = null)
+ {
+ Assert.AreEqual(normalBank, hitObject.Samples[0].Bank);
+
+ if (additionsBank != null)
+ Assert.AreEqual(additionsBank, hitObject.Samples[1].Bank);
+ }
+ }
+
[Test]
public void TestFallbackDecoderForCorruptedHeader()
{
diff --git a/osu.Game.Tests/Resources/invalid-bank.osu b/osu.Game.Tests/Resources/invalid-bank.osu
new file mode 100644
index 0000000000..8c554cc17f
--- /dev/null
+++ b/osu.Game.Tests/Resources/invalid-bank.osu
@@ -0,0 +1,19 @@
+osu file format v14
+
+[General]
+SampleSet: Normal
+
+[TimingPoints]
+0,500,4,3,0,100,1,0
+
+[HitObjects]
+256,192,1000,5,0,0:0:0:0:
+256,192,2000,1,0,1:0:0:0:
+256,192,3000,1,0,2:0:0:0:
+256,192,4000,1,0,3:0:0:0:
+256,192,5000,1,0,42:0:0:0:
+256,192,6000,5,4,0:0:0:0:
+256,192,7000,1,4,0:1:0:0:
+256,192,8000,1,4,0:2:0:0:
+256,192,9000,1,4,0:3:0:0:
+256,192,10000,1,4,0:42:0:0:
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
index d4018be7fc..fed26d8acb 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
@@ -260,6 +260,12 @@ namespace osu.Game.Tests.Visual.Beatmaps
AddStep($"set {scheme} scheme", () => Child = createContent(scheme, creationFunc));
}
+ [Test]
+ public void TestNano()
+ {
+ createTestCase(beatmapSetInfo => new BeatmapCardNano(beatmapSetInfo));
+ }
+
[Test]
public void TestNormal()
{
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs
index e86302bbd1..63fc4e47f9 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs
@@ -67,6 +67,11 @@ namespace osu.Game.Tests.Visual.Gameplay
private Player loadPlayerFor(RulesetInfo rulesetInfo)
{
+ // if a player screen is present already, we must exit that before loading another one,
+ // otherwise it'll crash on SpectatorClient.BeginPlaying being called while client is in "playing" state already.
+ if (Stack.CurrentScreen is Player)
+ Stack.Exit();
+
Ruleset.Value = rulesetInfo;
var ruleset = rulesetInfo.CreateInstance();
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
index d4000c07e7..65f943d36b 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
@@ -6,11 +6,14 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.PolygonExtensions;
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.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.Play.HUD;
using osuTK;
@@ -139,6 +142,31 @@ namespace osu.Game.Tests.Visual.Gameplay
=> AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount);
}
+ [Test]
+ public void TestFriendScore()
+ {
+ APIUser friend = new APIUser { Username = "my friend", Id = 10000 };
+
+ createLeaderboard();
+ addLocalPlayer();
+
+ AddStep("Add friend to API", () =>
+ {
+ var api = (DummyAPIAccess)API;
+
+ api.Friends.Clear();
+ api.Friends.Add(friend);
+ });
+
+ int playerNumber = 1;
+
+ AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3);
+ AddUntilStep("there are no pink color score", () => leaderboard.ChildrenOfType().All(b => b.Colour != Color4Extensions.FromHex("ff549a")));
+
+ AddRepeatStep("add 3 friend score", () => createRandomScore(friend), 3);
+ AddUntilStep("there are pink color for friend score", () => leaderboard.GetScoreByUsername("my friend").ChildrenOfType().Any(b => b.Colour == Color4Extensions.FromHex("ff549a")));
+ }
+
private void addLocalPlayer()
{
AddStep("add local player", () =>
@@ -179,6 +207,11 @@ namespace osu.Game.Tests.Visual.Gameplay
return scoreItem != null && scoreItem.ScorePosition == expectedPosition;
}
+
+ public GameplayLeaderboardScore GetScoreByUsername(string username)
+ {
+ return Flow.FirstOrDefault(i => i.User?.Username == username);
+ }
}
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs
deleted file mode 100644
index 5db05ea67c..0000000000
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs
+++ /dev/null
@@ -1,636 +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.Linq;
-using NUnit.Framework;
-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.Cursor;
-using osu.Framework.Graphics.Shapes;
-using osu.Framework.Graphics.Sprites;
-using osu.Framework.Input.Events;
-using osu.Game.Graphics;
-using osu.Game.Graphics.Containers;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Graphics.UserInterface;
-using osu.Game.Overlays.Settings;
-using osu.Game.Rulesets.Osu.Scoring;
-using osu.Game.Rulesets.Osu.Beatmaps;
-using osu.Game.Rulesets.Osu.Judgements;
-using osu.Game.Rulesets.Osu.Objects;
-using osu.Game.Rulesets.Scoring;
-using osu.Game.Scoring.Legacy;
-using osuTK;
-using osuTK.Graphics;
-using osuTK.Input;
-
-namespace osu.Game.Tests.Visual.Gameplay
-{
- public partial class TestSceneScoring : OsuTestScene
- {
- private GraphContainer graphs = null!;
- private SettingsSlider sliderMaxCombo = null!;
- private SettingsCheckbox scaleToMax = null!;
-
- private FillFlowContainer legend = null!;
-
- private readonly BindableBool standardisedVisible = new BindableBool(true);
- private readonly BindableBool classicVisible = new BindableBool(true);
- private readonly BindableBool scoreV1Visible = new BindableBool(true);
- private readonly BindableBool scoreV2Visible = new BindableBool(true);
-
- [Resolved]
- private OsuColour colours { get; set; } = null!;
-
- [Test]
- public void TestBasic()
- {
- AddStep("setup tests", () =>
- {
- Children = new Drawable[]
- {
- new Box
- {
- RelativeSizeAxes = Axes.Both,
- Colour = Colour4.Black
- },
- new GridContainer
- {
- RelativeSizeAxes = Axes.Both,
- RowDimensions = new[]
- {
- new Dimension(),
- new Dimension(GridSizeMode.AutoSize),
- new Dimension(GridSizeMode.AutoSize),
- },
- Content = new[]
- {
- new Drawable[]
- {
- graphs = new GraphContainer
- {
- RelativeSizeAxes = Axes.Both,
- },
- },
- new Drawable[]
- {
- legend = new FillFlowContainer
- {
- Padding = new MarginPadding(20),
- Direction = FillDirection.Vertical,
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- },
- },
- new Drawable[]
- {
- new FillFlowContainer
- {
- Padding = new MarginPadding(20),
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Direction = FillDirection.Vertical,
- Spacing = new Vector2(10),
- Children = new Drawable[]
- {
- sliderMaxCombo = new SettingsSlider
- {
- TransferValueOnCommit = true,
- Current = new BindableInt(1024)
- {
- MinValue = 96,
- MaxValue = 8192,
- },
- LabelText = "Max combo",
- },
- scaleToMax = new SettingsCheckbox
- {
- LabelText = "Rescale plots to 100%",
- Current = { Value = true, Default = true }
- },
- new OsuTextFlowContainer
- {
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Text = $"Left click to add miss\nRight click to add OK/{base_ok}",
- Margin = new MarginPadding { Top = 20 }
- }
- }
- },
- },
- }
- }
- };
-
- sliderMaxCombo.Current.BindValueChanged(_ => rerun());
- scaleToMax.Current.BindValueChanged(_ => rerun());
-
- standardisedVisible.BindValueChanged(_ => rescalePlots());
- classicVisible.BindValueChanged(_ => rescalePlots());
- scoreV1Visible.BindValueChanged(_ => rescalePlots());
- scoreV2Visible.BindValueChanged(_ => rescalePlots());
-
- graphs.MissLocations.BindCollectionChanged((_, __) => rerun());
- graphs.NonPerfectLocations.BindCollectionChanged((_, __) => rerun());
-
- graphs.MaxCombo.BindTo(sliderMaxCombo.Current);
-
- rerun();
- });
- }
-
- private const int base_great = 300;
- private const int base_ok = 100;
-
- private void rerun()
- {
- graphs.Clear();
- legend.Clear();
-
- runForProcessor("lazer-standardised", colours.Green1, new OsuScoreProcessor(), ScoringMode.Standardised, standardisedVisible);
- runForProcessor("lazer-classic", colours.Blue1, new OsuScoreProcessor(), ScoringMode.Classic, classicVisible);
-
- runScoreV1();
- runScoreV2();
-
- rescalePlots();
- }
-
- private void rescalePlots()
- {
- if (!scaleToMax.Current.Value && legend.Any(entry => entry.Visible.Value))
- {
- long maxScore = legend.Where(entry => entry.Visible.Value).Max(entry => entry.FinalScore);
-
- foreach (var graph in graphs)
- graph.Height = graph.Values.Max() / maxScore;
- }
- else
- {
- foreach (var graph in graphs)
- graph.Height = 1;
- }
- }
-
- private void runScoreV1()
- {
- int totalScore = 0;
- int currentCombo = 0;
-
- void applyHitV1(int baseScore)
- {
- if (baseScore == 0)
- {
- currentCombo = 0;
- return;
- }
-
- // this corresponds to stable's `ScoreMultiplier`.
- // value is chosen arbitrarily, towards the upper range.
- const float score_multiplier = 4;
-
- totalScore += baseScore;
-
- // combo multiplier
- // ReSharper disable once PossibleLossOfFraction
- totalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * score_multiplier));
-
- currentCombo++;
- }
-
- runForAlgorithm(new ScoringAlgorithm
- {
- Name = "ScoreV1 (classic)",
- Colour = colours.Purple1,
- ApplyHit = () => applyHitV1(base_great),
- ApplyNonPerfect = () => applyHitV1(base_ok),
- ApplyMiss = () => applyHitV1(0),
- GetTotalScore = () => totalScore,
- Visible = scoreV1Visible
- });
- }
-
- private void runScoreV2()
- {
- int maxCombo = sliderMaxCombo.Current.Value;
-
- int currentCombo = 0;
- double comboPortion = 0;
- double currentBaseScore = 0;
- double maxBaseScore = 0;
- int currentHits = 0;
-
- for (int i = 0; i < maxCombo; i++)
- applyHitV2(base_great);
-
- double comboPortionMax = comboPortion;
-
- currentCombo = 0;
- comboPortion = 0;
- currentBaseScore = 0;
- maxBaseScore = 0;
- currentHits = 0;
-
- void applyHitV2(int baseScore)
- {
- maxBaseScore += base_great;
- currentBaseScore += baseScore;
- comboPortion += baseScore * (1 + ++currentCombo / 10.0);
-
- currentHits++;
- }
-
- runForAlgorithm(new ScoringAlgorithm
- {
- Name = "ScoreV2",
- Colour = colours.Red1,
- ApplyHit = () => applyHitV2(base_great),
- ApplyNonPerfect = () => applyHitV2(base_ok),
- ApplyMiss = () =>
- {
- currentHits++;
- maxBaseScore += base_great;
- currentCombo = 0;
- },
- GetTotalScore = () =>
- {
- double accuracy = currentBaseScore / maxBaseScore;
-
- return (int)Math.Round
- (
- 700000 * comboPortion / comboPortionMax +
- 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo)
- );
- },
- Visible = scoreV2Visible
- });
- }
-
- private void runForProcessor(string name, Color4 colour, ScoreProcessor processor, ScoringMode mode, BindableBool visibility)
- {
- int maxCombo = sliderMaxCombo.Current.Value;
-
- var beatmap = new OsuBeatmap();
- for (int i = 0; i < maxCombo; i++)
- beatmap.HitObjects.Add(new HitCircle());
-
- processor.ApplyBeatmap(beatmap);
-
- runForAlgorithm(new ScoringAlgorithm
- {
- Name = name,
- Colour = colour,
- ApplyHit = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }),
- ApplyNonPerfect = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }),
- ApplyMiss = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }),
- GetTotalScore = () => processor.GetDisplayScore(mode),
- Visible = visibility
- });
- }
-
- private void runForAlgorithm(ScoringAlgorithm scoringAlgorithm)
- {
- int maxCombo = sliderMaxCombo.Current.Value;
-
- List results = new List();
-
- for (int i = 0; i < maxCombo; i++)
- {
- if (graphs.MissLocations.Contains(i))
- scoringAlgorithm.ApplyMiss();
- else if (graphs.NonPerfectLocations.Contains(i))
- scoringAlgorithm.ApplyNonPerfect();
- else
- scoringAlgorithm.ApplyHit();
-
- results.Add(scoringAlgorithm.GetTotalScore());
- }
-
- LineGraph graph;
- graphs.Add(graph = new LineGraph
- {
- Name = scoringAlgorithm.Name,
- Anchor = Anchor.BottomLeft,
- Origin = Anchor.BottomLeft,
- RelativeSizeAxes = Axes.Both,
- LineColour = scoringAlgorithm.Colour,
- Values = results
- });
-
- legend.Add(new LegendEntry(scoringAlgorithm, graph)
- {
- AccentColour = scoringAlgorithm.Colour,
- });
- }
- }
-
- public class ScoringAlgorithm
- {
- public string Name { get; init; } = null!;
- public Color4 Colour { get; init; }
- public Action ApplyHit { get; init; } = () => { };
- public Action ApplyNonPerfect { get; init; } = () => { };
- public Action ApplyMiss { get; init; } = () => { };
- public Func GetTotalScore { get; init; } = null!;
- public BindableBool Visible { get; init; } = null!;
- }
-
- public partial class GraphContainer : Container, IHasCustomTooltip>
- {
- public readonly BindableList MissLocations = new BindableList();
- public readonly BindableList NonPerfectLocations = new BindableList();
-
- public Bindable MaxCombo = new Bindable();
-
- protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
-
- private readonly Box hoverLine;
-
- private readonly Container missLines;
- private readonly Container verticalGridLines;
-
- public int CurrentHoverCombo { get; private set; }
-
- public GraphContainer()
- {
- InternalChild = new Container
- {
- RelativeSizeAxes = Axes.Both,
- Children = new Drawable[]
- {
- new Box
- {
- Colour = OsuColour.Gray(0.1f),
- RelativeSizeAxes = Axes.Both,
- },
- verticalGridLines = new Container
- {
- RelativeSizeAxes = Axes.Both,
- },
- hoverLine = new Box
- {
- Colour = Color4.Yellow,
- RelativeSizeAxes = Axes.Y,
- Origin = Anchor.TopCentre,
- Alpha = 0,
- Width = 1,
- },
- missLines = new Container
- {
- Alpha = 0.6f,
- RelativeSizeAxes = Axes.Both,
- },
- Content,
- }
- };
-
- MissLocations.BindCollectionChanged((_, _) => updateMissLocations());
- NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations());
-
- MaxCombo.BindValueChanged(_ =>
- {
- updateMissLocations();
- updateVerticalGridLines();
- }, true);
- }
-
- private void updateVerticalGridLines()
- {
- verticalGridLines.Clear();
-
- for (int i = 0; i < MaxCombo.Value; i++)
- {
- if (i % 100 == 0)
- {
- verticalGridLines.AddRange(new Drawable[]
- {
- new Box
- {
- Colour = OsuColour.Gray(0.2f),
- Origin = Anchor.TopCentre,
- Width = 1,
- RelativeSizeAxes = Axes.Y,
- RelativePositionAxes = Axes.X,
- X = (float)i / MaxCombo.Value,
- },
- new OsuSpriteText
- {
- RelativePositionAxes = Axes.X,
- X = (float)i / MaxCombo.Value,
- Anchor = Anchor.BottomLeft,
- Origin = Anchor.BottomLeft,
- Text = $"{i:#,0}",
- Rotation = -30,
- Y = -20,
- }
- });
- }
- }
- }
-
- private void updateMissLocations()
- {
- missLines.Clear();
-
- foreach (int miss in MissLocations)
- {
- missLines.Add(new Box
- {
- Colour = Color4.Red,
- Origin = Anchor.TopCentre,
- Width = 1,
- RelativeSizeAxes = Axes.Y,
- RelativePositionAxes = Axes.X,
- X = (float)miss / MaxCombo.Value,
- });
- }
-
- foreach (int miss in NonPerfectLocations)
- {
- missLines.Add(new Box
- {
- Colour = Color4.Orange,
- Origin = Anchor.TopCentre,
- Width = 1,
- RelativeSizeAxes = Axes.Y,
- RelativePositionAxes = Axes.X,
- X = (float)miss / MaxCombo.Value,
- });
- }
- }
-
- protected override bool OnHover(HoverEvent e)
- {
- hoverLine.Show();
- return base.OnHover(e);
- }
-
- protected override void OnHoverLost(HoverLostEvent e)
- {
- hoverLine.Hide();
- base.OnHoverLost(e);
- }
-
- protected override bool OnMouseMove(MouseMoveEvent e)
- {
- CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value);
-
- hoverLine.X = e.MousePosition.X;
- return base.OnMouseMove(e);
- }
-
- protected override bool OnMouseDown(MouseDownEvent e)
- {
- if (e.Button == MouseButton.Left)
- MissLocations.Add(CurrentHoverCombo);
- else
- NonPerfectLocations.Add(CurrentHoverCombo);
-
- return true;
- }
-
- private GraphTooltip? tooltip;
-
- public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this);
-
- public IEnumerable TooltipContent => Content;
-
- public partial class GraphTooltip : CompositeDrawable, ITooltip>
- {
- private readonly GraphContainer graphContainer;
-
- private readonly OsuTextFlowContainer textFlow;
-
- public GraphTooltip(GraphContainer graphContainer)
- {
- this.graphContainer = graphContainer;
- AutoSizeAxes = Axes.Both;
-
- Masking = true;
- CornerRadius = 10;
-
- InternalChildren = new Drawable[]
- {
- new Box
- {
- Colour = OsuColour.Gray(0.15f),
- RelativeSizeAxes = Axes.Both,
- },
- textFlow = new OsuTextFlowContainer
- {
- Colour = Color4.White,
- AutoSizeAxes = Axes.Both,
- Padding = new MarginPadding(10),
- }
- };
- }
-
- private int? lastContentCombo;
-
- public void SetContent(IEnumerable content)
- {
- int relevantCombo = graphContainer.CurrentHoverCombo;
-
- if (lastContentCombo == relevantCombo)
- return;
-
- lastContentCombo = relevantCombo;
- textFlow.Clear();
-
- textFlow.AddParagraph($"At combo {relevantCombo}:");
-
- foreach (var graph in content)
- {
- if (graph.Alpha == 0) continue;
-
- float valueAtHover = graph.Values.ElementAt(relevantCombo);
- float ofTotal = valueAtHover / graph.Values.Last();
-
- textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour);
- }
- }
-
- public void Move(Vector2 pos) => this.MoveTo(pos);
- }
- }
-
- public partial class LegendEntry : OsuClickableContainer, IHasAccentColour
- {
- public Color4 AccentColour { get; set; }
-
- public BindableBool Visible { get; } = new BindableBool(true);
-
- public readonly long FinalScore;
-
- private readonly string description;
- private readonly LineGraph lineGraph;
-
- private OsuSpriteText descriptionText = null!;
- private OsuSpriteText finalScoreText = null!;
-
- public LegendEntry(ScoringAlgorithm scoringAlgorithm, LineGraph lineGraph)
- {
- description = scoringAlgorithm.Name;
- FinalScore = scoringAlgorithm.GetTotalScore();
- AccentColour = scoringAlgorithm.Colour;
- Visible.BindTo(scoringAlgorithm.Visible);
-
- this.lineGraph = lineGraph;
- }
-
- [BackgroundDependencyLoader]
- private void load()
- {
- RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X;
- AutoSizeAxes = Content.AutoSizeAxes = Axes.Y;
-
- Children = new Drawable[]
- {
- descriptionText = new OsuSpriteText
- {
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- },
- finalScoreText = new OsuSpriteText
- {
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- Font = OsuFont.Default.With(fixedWidth: true)
- }
- };
- }
-
- protected override void LoadComplete()
- {
- base.LoadComplete();
- Visible.BindValueChanged(_ => updateState(), true);
- Action = Visible.Toggle;
- }
-
- protected override bool OnHover(HoverEvent e)
- {
- updateState();
- return true;
- }
-
- protected override void OnHoverLost(HoverLostEvent e)
- {
- updateState();
- base.OnHoverLost(e);
- }
-
- private void updateState()
- {
- Colour = IsHovered ? AccentColour.Lighten(0.2f) : AccentColour;
-
- descriptionText.Text = $"{(Visible.Value ? FontAwesome.Solid.CheckCircle.Icon : FontAwesome.Solid.Circle.Icon)} {description}";
- finalScoreText.Text = FinalScore.ToString("#,0");
- lineGraph.Alpha = Visible.Value ? 1 : 0;
- }
- }
-}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs
new file mode 100644
index 0000000000..60197e0eb7
--- /dev/null
+++ b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs
@@ -0,0 +1,97 @@
+// 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.Net;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Tests.Resources;
+
+namespace osu.Game.Tests.Visual.Online
+{
+ public partial class TestSceneReplayMissingBeatmap : OsuGameTestScene
+ {
+ private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
+
+ [Test]
+ public void TestSceneMissingBeatmapWithOnlineAvailable()
+ {
+ var beatmap = new APIBeatmap
+ {
+ OnlineBeatmapSetID = 173612,
+ BeatmapSet = new APIBeatmapSet
+ {
+ Title = "FREEDOM Dive",
+ Artist = "xi",
+ Covers = new BeatmapSetOnlineCovers
+ {
+ Card = "https://assets.ppy.sh/beatmaps/173612/covers/card@2x.jpg"
+ },
+ OnlineID = 173612
+ }
+ };
+
+ setupBeatmapResponse(beatmap);
+
+ AddStep("import score", () =>
+ {
+ using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"))
+ {
+ var importTask = new ImportTask(resourceStream, "replay.osr");
+
+ Game.ScoreManager.Import(new[] { importTask });
+ }
+ });
+
+ AddUntilStep("Replay missing notification show", () => Game.Notifications.ChildrenOfType().Any());
+ }
+
+ [Test]
+ public void TestSceneMissingBeatmapWithOnlineUnavailable()
+ {
+ setupFailedResponse();
+
+ AddStep("import score", () =>
+ {
+ using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"))
+ {
+ var importTask = new ImportTask(resourceStream, "replay.osr");
+
+ Game.ScoreManager.Import(new[] { importTask });
+ }
+ });
+
+ AddUntilStep("Replay missing notification not show", () => !Game.Notifications.ChildrenOfType().Any());
+ }
+
+ private void setupBeatmapResponse(APIBeatmap b)
+ => AddStep("setup response", () =>
+ {
+ dummyAPI.HandleRequest = request =>
+ {
+ if (request is GetBeatmapRequest getBeatmapRequest)
+ {
+ getBeatmapRequest.TriggerSuccess(b);
+ return true;
+ }
+
+ return false;
+ };
+ });
+
+ private void setupFailedResponse()
+ => AddStep("setup failed response", () =>
+ {
+ dummyAPI.HandleRequest = request =>
+ {
+ request.TriggerFailure(new WebException());
+ return true;
+ };
+ });
+ }
+}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs
new file mode 100644
index 0000000000..f5506edf3b
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.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 System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Database;
+using osu.Game.Overlays;
+using osu.Game.Tests.Scores.IO;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+ [TestFixture]
+ public partial class TestSceneMissingBeatmapNotification : OsuTestScene
+ {
+ [Cached]
+ private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Child = new Container
+ {
+ Width = 280,
+ AutoSizeAxes = Axes.Y,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), new ImportScoreTest.TestArchiveReader(), "deadbeef")
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj
index 59a786a11d..ef6c16f2c4 100644
--- a/osu.Game.Tests/osu.Game.Tests.csproj
+++ b/osu.Game.Tests/osu.Game.Tests.csproj
@@ -2,10 +2,10 @@
-
+
-
+
diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj
index 5847079161..2cc07dd9ed 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/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs
index 07dc2bea54..f80f43bb77 100644
--- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs
+++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs
@@ -126,7 +126,7 @@ namespace osu.Game.Tournament.Screens.MapPool
if (CurrentMatch.Value == null || CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2)
return;
- // if bans have already been placed, beatmap changes result in a selection being made autoamtically
+ // if bans have already been placed, beatmap changes result in a selection being made automatically
if (beatmap.NewValue?.OnlineID > 0)
addForBeatmap(beatmap.NewValue.OnlineID);
}
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index d71d7b7f67..1f551f1218 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -26,6 +26,7 @@ using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Skinning;
using osu.Game.Utils;
+using Realms;
namespace osu.Game.Beatmaps
{
@@ -284,7 +285,7 @@ namespace osu.Game.Beatmaps
///
/// The query.
/// The first result for the provided query, or null if no results were found.
- public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => r.All().FirstOrDefault(query)?.Detach());
+ public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach());
///
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
index 94b2956b4e..25e42bcbf7 100644
--- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
public abstract partial class BeatmapCard : OsuClickableContainer, IHasContextMenu
{
- public const float TRANSITION_DURATION = 400;
+ public const float TRANSITION_DURATION = 340;
public const float CORNER_RADIUS = 10;
protected const float WIDTH = 430;
@@ -89,6 +89,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
switch (size)
{
+ case BeatmapCardSize.Nano:
+ return new BeatmapCardNano(beatmapSet);
+
case BeatmapCardSize.Normal:
return new BeatmapCardNormal(beatmapSet, allowExpansion);
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs
new file mode 100644
index 0000000000..4ab2b0c973
--- /dev/null
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs
@@ -0,0 +1,169 @@
+// 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;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays;
+using osu.Game.Resources.Localisation.Web;
+using osuTK;
+
+namespace osu.Game.Beatmaps.Drawables.Cards
+{
+ public partial class BeatmapCardNano : BeatmapCard
+ {
+ protected override Drawable IdleContent => idleBottomContent;
+ protected override Drawable DownloadInProgressContent => downloadProgressBar;
+
+ public override float Width
+ {
+ get => base.Width;
+ set
+ {
+ base.Width = value;
+
+ if (LoadState >= LoadState.Ready)
+ buttonContainer.Width = value;
+ }
+ }
+
+ private const float height = 60;
+ private const float width = 300;
+
+ [Cached]
+ private readonly BeatmapCardContent content;
+
+ private CollapsibleButtonContainer buttonContainer = null!;
+
+ private FillFlowContainer idleBottomContent = null!;
+ private BeatmapCardDownloadProgressBar downloadProgressBar = null!;
+
+ [Resolved]
+ private OverlayColourProvider colourProvider { get; set; } = null!;
+
+ public BeatmapCardNano(APIBeatmapSet beatmapSet)
+ : base(beatmapSet, false)
+ {
+ content = new BeatmapCardContent(height);
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Width = width;
+ Height = height;
+
+ Child = content.With(c =>
+ {
+ c.MainContent = new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = height,
+ Children = new Drawable[]
+ {
+ buttonContainer = new CollapsibleButtonContainer(BeatmapSet)
+ {
+ Width = Width,
+ FavouriteState = { BindTarget = FavouriteState },
+ ButtonsCollapsedWidth = 5,
+ ButtonsExpandedWidth = 30,
+ Children = new Drawable[]
+ {
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Children = new Drawable[]
+ {
+ new TruncatingSpriteText
+ {
+ Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title),
+ Font = OsuFont.Default.With(size: 19, weight: FontWeight.SemiBold),
+ RelativeSizeAxes = Axes.X,
+ },
+ new TruncatingSpriteText
+ {
+ Text = createArtistText(),
+ Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold),
+ RelativeSizeAxes = Axes.X,
+ },
+ }
+ },
+ new Container
+ {
+ Name = @"Bottom content",
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ Children = new Drawable[]
+ {
+ idleBottomContent = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 3),
+ AlwaysPresent = true,
+ Children = new Drawable[]
+ {
+ new LinkFlowContainer(s =>
+ {
+ s.Shadow = false;
+ s.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold);
+ }).With(d =>
+ {
+ d.AutoSizeAxes = Axes.Both;
+ d.Margin = new MarginPadding { Top = 2 };
+ d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
+ d.AddUserLink(BeatmapSet.Author);
+ }),
+ }
+ },
+ downloadProgressBar = new BeatmapCardDownloadProgressBar
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = 6,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ State = { BindTarget = DownloadTracker.State },
+ Progress = { BindTarget = DownloadTracker.Progress }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+ c.ExpandedContent = new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Padding = new MarginPadding { Horizontal = 10, Vertical = 13 },
+ Child = new BeatmapCardDifficultyList(BeatmapSet)
+ };
+ c.Expanded.BindTarget = Expanded;
+ });
+ }
+
+ private LocalisableString createArtistText()
+ {
+ var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist);
+ return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist);
+ }
+
+ protected override void UpdateState()
+ {
+ base.UpdateState();
+
+ bool showDetails = IsHovered;
+
+ buttonContainer.ShowDetails.Value = showDetails;
+ }
+ }
+}
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs
index 098265506d..0b5acc4a05 100644
--- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs
@@ -8,6 +8,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
///
public enum BeatmapCardSize
{
+ Nano,
Normal,
Extra
}
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs
index ad91615031..5a26a988fb 100644
--- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs
@@ -3,15 +3,15 @@
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.Beatmaps.Drawables.Cards.Buttons;
-using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Framework.Graphics.UserInterface;
using osuTK;
-using osuTK.Graphics;
namespace osu.Game.Beatmaps.Drawables.Cards
{
@@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
set => foreground.Padding = value;
}
- private readonly UpdateableOnlineBeatmapSetCover cover;
+ private readonly Box background;
private readonly Container foreground;
private readonly PlayButton playButton;
private readonly CircularProgress progress;
@@ -33,15 +33,22 @@ namespace osu.Game.Beatmaps.Drawables.Cards
protected override Container Content => content;
+ [Resolved]
+ private OverlayColourProvider colourProvider { get; set; } = null!;
+
public BeatmapCardThumbnail(APIBeatmapSet beatmapSetInfo)
{
InternalChildren = new Drawable[]
{
- cover = new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List)
+ new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List)
{
RelativeSizeAxes = Axes.Both,
OnlineInfo = beatmapSetInfo
},
+ background = new Box
+ {
+ RelativeSizeAxes = Axes.Both
+ },
foreground = new Container
{
RelativeSizeAxes = Axes.Both,
@@ -68,7 +75,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
}
[BackgroundDependencyLoader]
- private void load(OverlayColourProvider colourProvider)
+ private void load()
{
progress.Colour = colourProvider.Highlight1;
}
@@ -89,7 +96,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
bool shouldDim = Dimmed.Value || playButton.Playing.Value;
playButton.FadeTo(shouldDim ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
- cover.FadeColour(shouldDim ? OsuColour.Gray(0.2f) : Color4.White, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
+ background.FadeColour(colourProvider.Background6.Opacity(shouldDim ? 0.8f : 0f), BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
}
}
}
diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index 921284ad4d..db71ff4e84 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -64,7 +64,13 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.Username, string.Empty);
SetDefault(OsuSetting.Token, string.Empty);
+#pragma warning disable CS0618 // Type or member is obsolete
+ // this default set MUST remain despite the setting being deprecated, because `SetDefault()` calls are implicitly used to declare the type returned for the lookup.
+ // if this is removed, the setting will be interpreted as a string, and `Migrate()` will fail due to cast failure.
+ // can be removed 20240618
SetDefault(OsuSetting.AutomaticallyDownloadWhenSpectating, false);
+#pragma warning restore CS0618 // Type or member is obsolete
+ SetDefault(OsuSetting.AutomaticallyDownloadMissingBeatmaps, false);
SetDefault(OsuSetting.SavePassword, false).ValueChanged += enabled =>
{
@@ -215,6 +221,12 @@ namespace osu.Game.Configuration
// migrations can be added here using a condition like:
// if (combined < 20220103) { performMigration() }
+ if (combined < 20230918)
+ {
+#pragma warning disable CS0618 // Type or member is obsolete
+ SetValue(OsuSetting.AutomaticallyDownloadMissingBeatmaps, Get(OsuSetting.AutomaticallyDownloadWhenSpectating)); // can be removed 20240618
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
}
public override TrackedSettings CreateTrackedSettings()
@@ -383,13 +395,17 @@ namespace osu.Game.Configuration
EditorShowHitMarkers,
EditorAutoSeekOnPlacement,
DiscordRichPresence,
+
+ [Obsolete($"Use {nameof(AutomaticallyDownloadMissingBeatmaps)} instead.")] // can be removed 20240318
AutomaticallyDownloadWhenSpectating,
+
ShowOnlineExplicitContent,
LastProcessedMetadataId,
SafeAreaConsiderations,
ComboColourNormalisationAmount,
ProfileCoverExpanded,
EditorLimitedDistanceSnap,
- ReplaySettingsOverlay
+ ReplaySettingsOverlay,
+ AutomaticallyDownloadMissingBeatmaps,
}
}
diff --git a/osu.Game/Database/MissingBeatmapNotification.cs b/osu.Game/Database/MissingBeatmapNotification.cs
new file mode 100644
index 0000000000..584b2675f3
--- /dev/null
+++ b/osu.Game/Database/MissingBeatmapNotification.cs
@@ -0,0 +1,103 @@
+// 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.Game.Beatmaps;
+using osu.Game.Beatmaps.Drawables.Cards;
+using osu.Game.Configuration;
+using osu.Game.IO.Archives;
+using osu.Game.Localisation;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays.Notifications;
+using osu.Game.Scoring;
+using Realms;
+
+namespace osu.Game.Database
+{
+ public partial class MissingBeatmapNotification : SimpleNotification
+ {
+ [Resolved]
+ private BeatmapModelDownloader beatmapDownloader { get; set; } = null!;
+
+ [Resolved]
+ private ScoreManager scoreManager { get; set; } = null!;
+
+ [Resolved]
+ private RealmAccess realm { get; set; } = null!;
+
+ private readonly ArchiveReader scoreArchive;
+ private readonly APIBeatmapSet beatmapSetInfo;
+ private readonly string beatmapHash;
+
+ private Bindable autoDownloadConfig = null!;
+ private Bindable noVideoSetting = null!;
+ private BeatmapCardNano card = null!;
+
+ private IDisposable? realmSubscription;
+
+ public MissingBeatmapNotification(APIBeatmap beatmap, ArchiveReader scoreArchive, string beatmapHash)
+ {
+ beatmapSetInfo = beatmap.BeatmapSet!;
+
+ this.beatmapHash = beatmapHash;
+ this.scoreArchive = scoreArchive;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ realmSubscription = realm.RegisterForNotifications(
+ realm => realm.All().Where(s => !s.DeletePending), beatmapsChanged);
+
+ autoDownloadConfig = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps);
+ noVideoSetting = config.GetBindable(OsuSetting.PreferNoVideo);
+
+ Content.Add(card = new BeatmapCardNano(beatmapSetInfo));
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ if (autoDownloadConfig.Value)
+ {
+ Text = NotificationsStrings.DownloadingBeatmapForReplay;
+ beatmapDownloader.Download(beatmapSetInfo, noVideoSetting.Value);
+ }
+ else
+ {
+ bool missingSetMatchesExistingOnlineId = realm.Run(r => r.All().Any(s => !s.DeletePending && s.OnlineID == beatmapSetInfo.OnlineID));
+ Text = missingSetMatchesExistingOnlineId ? NotificationsStrings.MismatchingBeatmapForReplay : NotificationsStrings.MissingBeatmapForReplay;
+ }
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+ card.Width = Content.DrawWidth;
+ }
+
+ private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes)
+ {
+ if (changes?.InsertedIndices == null) return;
+
+ if (sender.Any(s => s.Beatmaps.Any(b => b.MD5Hash == beatmapHash)))
+ {
+ string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase));
+ var importTask = new ImportTask(scoreArchive.GetStream(name), name);
+ scoreManager.Import(new[] { importTask });
+ realmSubscription?.Dispose();
+ Close(false);
+ }
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+ realmSubscription?.Dispose();
+ }
+ }
+}
diff --git a/osu.Game/IO/Archives/MemoryStreamArchiveReader.cs b/osu.Game/IO/Archives/MemoryStreamArchiveReader.cs
index 37ce1e508e..d8e1199e93 100644
--- a/osu.Game/IO/Archives/MemoryStreamArchiveReader.cs
+++ b/osu.Game/IO/Archives/MemoryStreamArchiveReader.cs
@@ -19,7 +19,7 @@ namespace osu.Game.IO.Archives
this.stream = stream;
}
- public override Stream GetStream(string name) => new MemoryStream(stream.GetBuffer(), 0, (int)stream.Length);
+ public override Stream GetStream(string name) => new MemoryStream(stream.ToArray(), 0, (int)stream.Length);
public override void Dispose()
{
diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs
index 53687f2b28..adbd7a354b 100644
--- a/osu.Game/Localisation/NotificationsStrings.cs
+++ b/osu.Game/Localisation/NotificationsStrings.cs
@@ -93,6 +93,21 @@ Please try changing your audio device to a working setting.");
///
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);
+ ///
+ /// "You do not have the beatmap for this replay."
+ ///
+ public static LocalisableString MissingBeatmapForReplay => new TranslatableString(getKey(@"missing_beatmap_for_replay"), @"You do not have the beatmap for this replay.");
+
+ ///
+ /// "Downloading missing beatmap for this replay..."
+ ///
+ public static LocalisableString DownloadingBeatmapForReplay => new TranslatableString(getKey(@"downloading_beatmap_for_replay"), @"Downloading missing beatmap for this replay...");
+
+ ///
+ /// "Your local copy of the beatmap for this replay appears to be different than expected. You may need to update or re-download it."
+ ///
+ public static LocalisableString MismatchingBeatmapForReplay => new TranslatableString(getKey(@"mismatching_beatmap_for_replay"), @"Your local copy of the beatmap for this replay appears to be different than expected. You may need to update or re-download it.");
+
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs
index 3200b1c75c..0660bac172 100644
--- a/osu.Game/Localisation/OnlineSettingsStrings.cs
+++ b/osu.Game/Localisation/OnlineSettingsStrings.cs
@@ -55,9 +55,9 @@ namespace osu.Game.Localisation
public static LocalisableString PreferNoVideo => new TranslatableString(getKey(@"prefer_no_video"), @"Prefer downloads without video");
///
- /// "Automatically download beatmaps when spectating"
+ /// "Automatically download missing beatmaps"
///
- public static LocalisableString AutomaticallyDownloadWhenSpectating => new TranslatableString(getKey(@"automatically_download_when_spectating"), @"Automatically download beatmaps when spectating");
+ public static LocalisableString AutomaticallyDownloadMissingBeatmaps => new TranslatableString(getKey(@"automatically_download_missing_beatmaps"), @"Automatically download missing beatmaps");
///
/// "Show explicit content in search results"
diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs
index 8df2d3fc2d..3fad032531 100644
--- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs
+++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs
@@ -35,8 +35,8 @@ namespace osu.Game.Online.API
for (int i = 0; i < itemCount; i++)
{
- output[reader.ReadString()] =
- PrimitiveObjectFormatter.Instance.Deserialize(ref reader, options);
+ output[reader.ReadString()!] =
+ PrimitiveObjectFormatter.Instance.Deserialize(ref reader, options)!;
}
return output;
diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs
index feb0c27ee7..9cd0031e3d 100644
--- a/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs
+++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs
@@ -22,8 +22,12 @@ namespace osu.Game.Overlays.BeatmapListing
public BeatmapListingCardSizeTabControl()
{
AutoSizeAxes = Axes.Both;
+
+ Items = new[] { BeatmapCardSize.Normal, BeatmapCardSize.Extra };
}
+ protected override bool AddEnumEntriesAutomatically => false;
+
protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer
{
AutoSizeAxes = Axes.Both,
diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs
index 8cdc373417..805604c274 100644
--- a/osu.Game/Overlays/Notifications/Notification.cs
+++ b/osu.Game/Overlays/Notifications/Notification.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Overlays.Notifications
public bool WasClosed { get; private set; }
- private readonly Container content;
+ private readonly FillFlowContainer content;
protected override Container Content => content;
@@ -166,11 +166,13 @@ namespace osu.Game.Overlays.Notifications
Padding = new MarginPadding(10),
Children = new Drawable[]
{
- content = new Container
+ content = new FillFlowContainer
{
Masking = true,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(15)
},
}
},
diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs
index d34b01ebf3..ce5c85bed0 100644
--- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs
@@ -31,9 +31,9 @@ namespace osu.Game.Overlays.Settings.Sections.Online
},
new SettingsCheckbox
{
- LabelText = OnlineSettingsStrings.AutomaticallyDownloadWhenSpectating,
- Keywords = new[] { "spectator" },
- Current = config.GetBindable(OsuSetting.AutomaticallyDownloadWhenSpectating),
+ LabelText = OnlineSettingsStrings.AutomaticallyDownloadMissingBeatmaps,
+ Keywords = new[] { "spectator", "replay" },
+ Current = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps),
},
new SettingsCheckbox
{
diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
index 3dbe7b6519..d20f2d31bb 100644
--- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
+++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
@@ -190,7 +190,12 @@ namespace osu.Game.Rulesets.Objects.Legacy
string[] split = str.Split(':');
var bank = (LegacySampleBank)Parsing.ParseInt(split[0]);
+ if (!Enum.IsDefined(bank))
+ bank = LegacySampleBank.Normal;
+
var addBank = (LegacySampleBank)Parsing.ParseInt(split[1]);
+ if (!Enum.IsDefined(addBank))
+ addBank = LegacySampleBank.Normal;
string stringBank = bank.ToString().ToLowerInvariant();
if (stringBank == @"none")
diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs
index 81b9f57bbc..b85b6a066e 100644
--- a/osu.Game/Scoring/ScoreImporter.cs
+++ b/osu.Game/Scoring/ScoreImporter.cs
@@ -54,7 +54,12 @@ namespace osu.Game.Scoring
}
catch (LegacyScoreDecoder.BeatmapNotFoundException e)
{
- Logger.Log($@"Score '{name}' failed to import: no corresponding beatmap with the hash '{e.Hash}' could be found.", LoggingTarget.Database);
+ Logger.Log($@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{e.Hash}' could be found.", LoggingTarget.Database);
+
+ // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap.
+ var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = e.Hash });
+ req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, e.Hash));
+ api.Queue(req);
return null;
}
}
diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
index dcb2c1071e..7471955493 100644
--- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
+++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
@@ -2,6 +2,7 @@
// 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;
@@ -11,6 +12,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
+using osu.Game.Online.API;
using osu.Game.Rulesets.Scoring;
using osu.Game.Users;
using osu.Game.Users.Drawables;
@@ -107,6 +109,8 @@ namespace osu.Game.Screens.Play.HUD
private IBindable scoreDisplayMode = null!;
+ private bool isFriend;
+
///
/// Creates a new .
///
@@ -124,7 +128,7 @@ namespace osu.Game.Screens.Play.HUD
}
[BackgroundDependencyLoader]
- private void load(OsuColour colours, OsuConfigManager osuConfigManager)
+ private void load(OsuColour colours, OsuConfigManager osuConfigManager, IAPIProvider api)
{
Container avatarContainer;
@@ -311,6 +315,8 @@ namespace osu.Game.Screens.Play.HUD
}, true);
HasQuit.BindValueChanged(_ => updateState());
+
+ isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.Id);
}
protected override void LoadComplete()
@@ -389,6 +395,11 @@ namespace osu.Game.Screens.Play.HUD
panelColour = BackgroundColour ?? Color4Extensions.FromHex("ffd966");
textColour = TextColour ?? Color4Extensions.FromHex("2e576b");
}
+ else if (isFriend)
+ {
+ panelColour = BackgroundColour ?? Color4Extensions.FromHex("ff549a");
+ textColour = TextColour ?? Color4.White;
+ }
else
{
panelColour = BackgroundColour ?? Color4Extensions.FromHex("3399cc");
diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs
index a5c84e97ab..f5af2684d3 100644
--- a/osu.Game/Screens/Play/SoloSpectator.cs
+++ b/osu.Game/Screens/Play/SoloSpectator.cs
@@ -143,7 +143,7 @@ namespace osu.Game.Screens.Play
automaticDownload = new SettingsCheckbox
{
LabelText = "Automatically download beatmaps",
- Current = config.GetBindable(OsuSetting.AutomaticallyDownloadWhenSpectating),
+ Current = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
index e674e7512c..c2a58d46ef 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
@@ -1,22 +1,23 @@
// 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.IO;
using System.Linq;
using System.Threading;
-using osuTK;
+using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Play;
+using osuTK;
namespace osu.Game.Storyboards.Drawables
{
@@ -57,12 +58,18 @@ namespace osu.Game.Storyboards.Drawables
[Cached(typeof(IReadOnlyList))]
public IReadOnlyList Mods { get; }
- private DependencyContainer dependencies;
+ [Resolved]
+ private GameHost host { get; set; } = null!;
+
+ [Resolved]
+ private RealmAccess realm { get; set; } = null!;
+
+ private DependencyContainer dependencies = null!;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
- public DrawableStoryboard(Storyboard storyboard, IReadOnlyList mods = null)
+ public DrawableStoryboard(Storyboard storyboard, IReadOnlyList? mods = null)
{
Storyboard = storyboard;
Mods = mods ?? Array.Empty();
@@ -85,12 +92,15 @@ namespace osu.Game.Storyboards.Drawables
}
[BackgroundDependencyLoader(true)]
- private void load(IGameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm)
+ private void load(IGameplayClock? clock, CancellationToken? cancellationToken)
{
if (clock != null)
Clock = clock;
- dependencies.Cache(new TextureStore(host.Renderer, host.CreateTextureLoaderStore(new RealmFileStore(realm, host.Storage).Store), false, scaleAdjust: 1));
+ dependencies.CacheAs(typeof(TextureStore),
+ new TextureStore(host.Renderer, host.CreateTextureLoaderStore(
+ CreateResourceLookupStore()
+ ), false, scaleAdjust: 1));
foreach (var layer in Storyboard.Layers)
{
@@ -102,6 +112,8 @@ namespace osu.Game.Storyboards.Drawables
lastEventEndTime = Storyboard.LatestEventTime;
}
+ protected virtual IResourceStore CreateResourceLookupStore() => new StoryboardResourceLookupStore(Storyboard, realm, host);
+
protected override void Update()
{
base.Update();
@@ -115,5 +127,50 @@ namespace osu.Game.Storyboards.Drawables
foreach (var layer in Children)
layer.Enabled = passing ? layer.Layer.VisibleWhenPassing : layer.Layer.VisibleWhenFailing;
}
+
+ private class StoryboardResourceLookupStore : IResourceStore
+ {
+ private readonly IResourceStore realmFileStore;
+ private readonly Storyboard storyboard;
+
+ public StoryboardResourceLookupStore(Storyboard storyboard, RealmAccess realm, GameHost host)
+ {
+ realmFileStore = new RealmFileStore(realm, host.Storage).Store;
+ this.storyboard = storyboard;
+ }
+
+ public void Dispose() =>
+ realmFileStore.Dispose();
+
+ public byte[] Get(string name)
+ {
+ string? storagePath = storyboard.GetStoragePathFromStoryboardPath(name);
+
+ return string.IsNullOrEmpty(storagePath)
+ ? null!
+ : realmFileStore.Get(storagePath);
+ }
+
+ public Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken())
+ {
+ string? storagePath = storyboard.GetStoragePathFromStoryboardPath(name);
+
+ return string.IsNullOrEmpty(storagePath)
+ ? Task.FromResult(null!)
+ : realmFileStore.GetAsync(storagePath, cancellationToken);
+ }
+
+ public Stream? GetStream(string name)
+ {
+ string? storagePath = storyboard.GetStoragePathFromStoryboardPath(name);
+
+ return string.IsNullOrEmpty(storagePath)
+ ? null
+ : realmFileStore.GetStream(storagePath);
+ }
+
+ public IEnumerable GetAvailableResources() =>
+ realmFileStore.GetAvailableResources();
+ }
}
}
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs
index 82c01ea6a1..054a50456b 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs
@@ -99,15 +99,13 @@ namespace osu.Game.Storyboards.Drawables
{
int frameIndex = 0;
- Texture frameTexture = storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore);
+ Texture frameTexture = textureStore.Get(getFramePath(frameIndex));
if (frameTexture != null)
{
// sourcing from storyboard.
for (frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++)
- {
- AddFrame(storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore), Animation.FrameDelay);
- }
+ AddFrame(textureStore.Get(getFramePath(frameIndex)), Animation.FrameDelay);
}
else if (storyboard.UseSkinSprites)
{
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs
index ec0cb7ca19..379de1a497 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs
@@ -90,7 +90,7 @@ namespace osu.Game.Storyboards.Drawables
[BackgroundDependencyLoader]
private void load(TextureStore textureStore, Storyboard storyboard)
{
- Texture = storyboard.GetTextureFromPath(Sprite.Path, textureStore);
+ Texture = textureStore.Get(Sprite.Path);
if (Texture == null && storyboard.UseSkinSprites)
{
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs
index eec2cd6a60..9a5db4bb39 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs
@@ -29,12 +29,7 @@ namespace osu.Game.Storyboards.Drawables
[BackgroundDependencyLoader(true)]
private void load(IBindable beatmap, TextureStore textureStore)
{
- string? path = beatmap.Value.BeatmapSetInfo?.GetPathForFile(Video.Path);
-
- if (path == null)
- return;
-
- var stream = textureStore.GetStream(path);
+ var stream = textureStore.GetStream(Video.Path);
if (stream == null)
return;
diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs
index 566e064aad..1892855d3d 100644
--- a/osu.Game/Storyboards/Storyboard.cs
+++ b/osu.Game/Storyboards/Storyboard.cs
@@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using osu.Framework.Graphics.Textures;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Storyboards.Drawables;
@@ -92,7 +91,7 @@ namespace osu.Game.Storyboards
private static readonly string[] image_extensions = { @".png", @".jpg" };
- public Texture? GetTextureFromPath(string path, TextureStore textureStore)
+ public virtual string? GetStoragePathFromStoryboardPath(string path)
{
string? resolvedPath = null;
@@ -102,10 +101,7 @@ namespace osu.Game.Storyboards
}
else
{
- // Just doing this extension logic locally here for simplicity.
- //
- // A more "sane" path may be to use the ISkinSource.GetTexture path (which will use the extensions of the underlying TextureStore),
- // but comes with potential complexity (what happens if the user has beatmap skins disabled?).
+ // Some old storyboards don't include a file extension, so let's best guess at one.
foreach (string ext in image_extensions)
{
if ((resolvedPath = BeatmapInfo.BeatmapSet?.GetPathForFile($"{path}{ext}")) != null)
@@ -113,10 +109,7 @@ namespace osu.Game.Storyboards
}
}
- if (!string.IsNullOrEmpty(resolvedPath))
- return textureStore.Get(resolvedPath);
-
- return null;
+ return resolvedPath;
}
}
}
diff --git a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs
new file mode 100644
index 0000000000..de4688a6fe
--- /dev/null
+++ b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs
@@ -0,0 +1,596 @@
+// 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.Color4Extensions;
+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.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays.Settings;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring.Legacy;
+using osuTK;
+using osuTK.Graphics;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public abstract partial class ScoringTestScene : OsuTestScene
+ {
+ protected abstract IBeatmap CreateBeatmap(int maxCombo);
+
+ protected abstract IScoringAlgorithm CreateScoreV1();
+ protected abstract IScoringAlgorithm CreateScoreV2(int maxCombo);
+ protected abstract ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode);
+
+ protected Bindable MaxCombo => sliderMaxCombo.Current;
+ protected BindableList NonPerfectLocations => graphs.NonPerfectLocations;
+ protected BindableList MissLocations => graphs.MissLocations;
+
+ private readonly bool supportsNonPerfectJudgements;
+
+ private GraphContainer graphs = null!;
+ private SettingsSlider sliderMaxCombo = null!;
+ private SettingsCheckbox scaleToMax = null!;
+
+ private FillFlowContainer legend = null!;
+
+ private readonly BindableBool standardisedVisible = new BindableBool(true);
+ private readonly BindableBool classicVisible = new BindableBool(true);
+ private readonly BindableBool scoreV1Visible = new BindableBool(true);
+ private readonly BindableBool scoreV2Visible = new BindableBool(true);
+
+ [Resolved]
+ private OsuColour colours { get; set; } = null!;
+
+ protected ScoringTestScene(bool supportsNonPerfectJudgements = true)
+ {
+ this.supportsNonPerfectJudgements = supportsNonPerfectJudgements;
+ }
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("setup tests", () =>
+ {
+ OsuTextFlowContainer clickExplainer;
+
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Colour4.Black
+ },
+ new GridContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ RowDimensions = new[]
+ {
+ new Dimension(),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ graphs = new GraphContainer(supportsNonPerfectJudgements)
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ },
+ new Drawable[]
+ {
+ legend = new FillFlowContainer
+ {
+ Padding = new MarginPadding(20),
+ Direction = FillDirection.Vertical,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ },
+ },
+ new Drawable[]
+ {
+ new FillFlowContainer
+ {
+ Padding = new MarginPadding(20),
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(10),
+ Children = new Drawable[]
+ {
+ sliderMaxCombo = new SettingsSlider
+ {
+ TransferValueOnCommit = true,
+ Current = new BindableInt(1024)
+ {
+ MinValue = 96,
+ MaxValue = 8192,
+ },
+ LabelText = "Max combo",
+ },
+ scaleToMax = new SettingsCheckbox
+ {
+ LabelText = "Rescale plots to 100%",
+ Current = { Value = true, Default = true }
+ },
+ clickExplainer = new OsuTextFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Margin = new MarginPadding { Top = 20 }
+ }
+ }
+ },
+ },
+ }
+ }
+ };
+
+ clickExplainer.AddParagraph("Left click to add miss");
+ if (supportsNonPerfectJudgements)
+ clickExplainer.AddParagraph("Right click to add OK");
+
+ sliderMaxCombo.Current.BindValueChanged(_ => Rerun());
+ scaleToMax.Current.BindValueChanged(_ => Rerun());
+
+ standardisedVisible.BindValueChanged(_ => rescalePlots());
+ classicVisible.BindValueChanged(_ => rescalePlots());
+ scoreV1Visible.BindValueChanged(_ => rescalePlots());
+ scoreV2Visible.BindValueChanged(_ => rescalePlots());
+
+ graphs.MissLocations.BindCollectionChanged((_, __) => Rerun());
+ graphs.NonPerfectLocations.BindCollectionChanged((_, __) => Rerun());
+
+ graphs.MaxCombo.BindTo(sliderMaxCombo.Current);
+
+ Rerun();
+ });
+ }
+
+ protected void Rerun()
+ {
+ graphs.Clear();
+ legend.Clear();
+
+ runForProcessor("lazer-standardised", colours.Green1, ScoringMode.Standardised, standardisedVisible);
+ runForProcessor("lazer-classic", colours.Blue1, ScoringMode.Classic, classicVisible);
+
+ runForAlgorithm(new ScoringAlgorithmInfo
+ {
+ Name = "ScoreV1 (classic)",
+ Colour = colours.Purple1,
+ Algorithm = CreateScoreV1(),
+ Visible = scoreV1Visible
+ });
+ runForAlgorithm(new ScoringAlgorithmInfo
+ {
+ Name = "ScoreV2",
+ Colour = colours.Red1,
+ Algorithm = CreateScoreV2(sliderMaxCombo.Current.Value),
+ Visible = scoreV2Visible
+ });
+
+ rescalePlots();
+ }
+
+ private void rescalePlots()
+ {
+ if (!scaleToMax.Current.Value && legend.Any(entry => entry.Visible.Value))
+ {
+ long maxScore = legend.Where(entry => entry.Visible.Value).Max(entry => entry.FinalScore);
+
+ foreach (var graph in graphs)
+ graph.Height = graph.Values.Max() / maxScore;
+ }
+ else
+ {
+ foreach (var graph in graphs)
+ graph.Height = 1;
+ }
+ }
+
+ private void runForProcessor(string name, Color4 colour, ScoringMode scoringMode, BindableBool visibility)
+ {
+ int maxCombo = sliderMaxCombo.Current.Value;
+ var beatmap = CreateBeatmap(maxCombo);
+ var algorithm = CreateScoreAlgorithm(beatmap, scoringMode);
+
+ runForAlgorithm(new ScoringAlgorithmInfo
+ {
+ Name = name,
+ Colour = colour,
+ Algorithm = algorithm,
+ Visible = visibility
+ });
+ }
+
+ private void runForAlgorithm(ScoringAlgorithmInfo algorithmInfo)
+ {
+ int maxCombo = sliderMaxCombo.Current.Value;
+
+ List results = new List();
+
+ for (int i = 0; i < maxCombo; i++)
+ {
+ if (graphs.MissLocations.Contains(i))
+ algorithmInfo.Algorithm.ApplyMiss();
+ else if (graphs.NonPerfectLocations.Contains(i))
+ algorithmInfo.Algorithm.ApplyNonPerfect();
+ else
+ algorithmInfo.Algorithm.ApplyHit();
+
+ results.Add(algorithmInfo.Algorithm.TotalScore);
+ }
+
+ LineGraph graph;
+ graphs.Add(graph = new LineGraph
+ {
+ Name = algorithmInfo.Name,
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ RelativeSizeAxes = Axes.Both,
+ LineColour = algorithmInfo.Colour,
+ Values = results
+ });
+
+ legend.Add(new LegendEntry(algorithmInfo, graph)
+ {
+ AccentColour = algorithmInfo.Colour,
+ });
+ }
+
+ private class ScoringAlgorithmInfo
+ {
+ public string Name { get; init; } = null!;
+ public Color4 Colour { get; init; }
+ public IScoringAlgorithm Algorithm { get; init; } = null!;
+ public BindableBool Visible { get; init; } = null!;
+ }
+
+ protected interface IScoringAlgorithm
+ {
+ void ApplyHit();
+ void ApplyNonPerfect();
+ void ApplyMiss();
+
+ long TotalScore { get; }
+ }
+
+ protected abstract class ProcessorBasedScoringAlgorithm : IScoringAlgorithm
+ {
+ protected abstract ScoreProcessor CreateScoreProcessor();
+ protected abstract JudgementResult CreatePerfectJudgementResult();
+ protected abstract JudgementResult CreateNonPerfectJudgementResult();
+ protected abstract JudgementResult CreateMissJudgementResult();
+
+ private readonly ScoreProcessor scoreProcessor;
+ private readonly ScoringMode mode;
+
+ protected ProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
+ {
+ this.mode = mode;
+ scoreProcessor = CreateScoreProcessor();
+ scoreProcessor.ApplyBeatmap(beatmap);
+ }
+
+ public void ApplyHit() => scoreProcessor.ApplyResult(CreatePerfectJudgementResult());
+ public void ApplyNonPerfect() => scoreProcessor.ApplyResult(CreateNonPerfectJudgementResult());
+ public void ApplyMiss() => scoreProcessor.ApplyResult(CreateMissJudgementResult());
+
+ public long TotalScore => scoreProcessor.GetDisplayScore(mode);
+ }
+
+ public partial class GraphContainer : Container, IHasCustomTooltip>
+ {
+ private readonly bool supportsNonPerfectJudgements;
+
+ public readonly BindableList MissLocations = new BindableList();
+ public readonly BindableList NonPerfectLocations = new BindableList();
+
+ public Bindable MaxCombo = new Bindable();
+
+ protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
+
+ private readonly Box hoverLine;
+
+ private readonly Container missLines;
+ private readonly Container verticalGridLines;
+
+ public int CurrentHoverCombo { get; private set; }
+
+ public GraphContainer(bool supportsNonPerfectJudgements)
+ {
+ this.supportsNonPerfectJudgements = supportsNonPerfectJudgements;
+ InternalChild = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ Colour = OsuColour.Gray(0.1f),
+ RelativeSizeAxes = Axes.Both,
+ },
+ verticalGridLines = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ hoverLine = new Box
+ {
+ Colour = Color4.Yellow,
+ RelativeSizeAxes = Axes.Y,
+ Origin = Anchor.TopCentre,
+ Alpha = 0,
+ Width = 1,
+ },
+ missLines = new Container
+ {
+ Alpha = 0.6f,
+ RelativeSizeAxes = Axes.Both,
+ },
+ Content,
+ }
+ };
+
+ MissLocations.BindCollectionChanged((_, _) => updateMissLocations());
+ NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations());
+
+ MaxCombo.BindValueChanged(_ =>
+ {
+ updateMissLocations();
+ updateVerticalGridLines();
+ }, true);
+ }
+
+ private void updateVerticalGridLines()
+ {
+ verticalGridLines.Clear();
+
+ for (int i = 0; i < MaxCombo.Value; i++)
+ {
+ if (i % 100 == 0)
+ {
+ verticalGridLines.AddRange(new Drawable[]
+ {
+ new Box
+ {
+ Colour = OsuColour.Gray(0.2f),
+ Origin = Anchor.TopCentre,
+ Width = 1,
+ RelativeSizeAxes = Axes.Y,
+ RelativePositionAxes = Axes.X,
+ X = (float)i / MaxCombo.Value,
+ },
+ new OsuSpriteText
+ {
+ RelativePositionAxes = Axes.X,
+ X = (float)i / MaxCombo.Value,
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ Text = $"{i:#,0}",
+ Rotation = -30,
+ Y = -20,
+ }
+ });
+ }
+ }
+ }
+
+ private void updateMissLocations()
+ {
+ missLines.Clear();
+
+ foreach (int miss in MissLocations)
+ {
+ missLines.Add(new Box
+ {
+ Colour = Color4.Red,
+ Origin = Anchor.TopCentre,
+ Width = 1,
+ RelativeSizeAxes = Axes.Y,
+ RelativePositionAxes = Axes.X,
+ X = (float)miss / MaxCombo.Value,
+ });
+ }
+
+ foreach (int miss in NonPerfectLocations)
+ {
+ missLines.Add(new Box
+ {
+ Colour = Color4.Orange,
+ Origin = Anchor.TopCentre,
+ Width = 1,
+ RelativeSizeAxes = Axes.Y,
+ RelativePositionAxes = Axes.X,
+ X = (float)miss / MaxCombo.Value,
+ });
+ }
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ hoverLine.Show();
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ hoverLine.Hide();
+ base.OnHoverLost(e);
+ }
+
+ protected override bool OnMouseMove(MouseMoveEvent e)
+ {
+ CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value);
+
+ hoverLine.X = e.MousePosition.X;
+ return base.OnMouseMove(e);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ if (e.Button == MouseButton.Left)
+ MissLocations.Add(CurrentHoverCombo);
+ else if (supportsNonPerfectJudgements)
+ NonPerfectLocations.Add(CurrentHoverCombo);
+
+ return true;
+ }
+
+ private GraphTooltip? tooltip;
+
+ public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this);
+
+ public IEnumerable TooltipContent => Content;
+
+ public partial class GraphTooltip : CompositeDrawable, ITooltip>
+ {
+ private readonly GraphContainer graphContainer;
+
+ private readonly OsuTextFlowContainer textFlow;
+
+ public GraphTooltip(GraphContainer graphContainer)
+ {
+ this.graphContainer = graphContainer;
+ AutoSizeAxes = Axes.Both;
+
+ Masking = true;
+ CornerRadius = 10;
+
+ InternalChildren = new Drawable[]
+ {
+ new Box
+ {
+ Colour = OsuColour.Gray(0.15f),
+ RelativeSizeAxes = Axes.Both,
+ },
+ textFlow = new OsuTextFlowContainer
+ {
+ Colour = Color4.White,
+ AutoSizeAxes = Axes.Both,
+ Padding = new MarginPadding(10),
+ }
+ };
+ }
+
+ private int? lastContentCombo;
+
+ public void SetContent(IEnumerable content)
+ {
+ int relevantCombo = graphContainer.CurrentHoverCombo;
+
+ if (lastContentCombo == relevantCombo)
+ return;
+
+ lastContentCombo = relevantCombo;
+ textFlow.Clear();
+
+ textFlow.AddParagraph($"At combo {relevantCombo}:");
+
+ foreach (var graph in content)
+ {
+ if (graph.Alpha == 0) continue;
+
+ float valueAtHover = graph.Values.ElementAt(relevantCombo);
+ float ofTotal = valueAtHover / graph.Values.Last();
+
+ textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour);
+ }
+ }
+
+ public void Move(Vector2 pos) => this.MoveTo(pos);
+ }
+ }
+
+ private partial class LegendEntry : OsuClickableContainer, IHasAccentColour
+ {
+ public Color4 AccentColour { get; set; }
+
+ public BindableBool Visible { get; } = new BindableBool(true);
+
+ public readonly long FinalScore;
+
+ private readonly string description;
+ private readonly LineGraph lineGraph;
+
+ private OsuSpriteText descriptionText = null!;
+ private OsuSpriteText finalScoreText = null!;
+
+ public LegendEntry(ScoringAlgorithmInfo scoringAlgorithmInfo, LineGraph lineGraph)
+ {
+ description = scoringAlgorithmInfo.Name;
+ FinalScore = scoringAlgorithmInfo.Algorithm.TotalScore;
+ AccentColour = scoringAlgorithmInfo.Colour;
+ Visible.BindTo(scoringAlgorithmInfo.Visible);
+
+ this.lineGraph = lineGraph;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X;
+ AutoSizeAxes = Content.AutoSizeAxes = Axes.Y;
+
+ Children = new Drawable[]
+ {
+ descriptionText = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ },
+ finalScoreText = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ Font = OsuFont.Default.With(fixedWidth: true)
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ Visible.BindValueChanged(_ => updateState(), true);
+ Action = Visible.Toggle;
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ updateState();
+ return true;
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ updateState();
+ base.OnHoverLost(e);
+ }
+
+ private void updateState()
+ {
+ Colour = IsHovered ? AccentColour.Lighten(0.2f) : AccentColour;
+
+ descriptionText.Text = $"{(Visible.Value ? FontAwesome.Solid.CheckCircle.Icon : FontAwesome.Solid.Circle.Icon)} {description}";
+ finalScoreText.Text = FinalScore.ToString("#,0");
+ lineGraph.Alpha = Visible.Value ? 1 : 0;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index c27e30d5bb..9dc24eff69 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -635,7 +635,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private T clone(T incoming)
{
- byte[]? serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS);
+ byte[] serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS);
return MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS);
}
diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs
index 0ec5a4c5c2..3ccb795a57 100644
--- a/osu.Game/Tests/Visual/OsuTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuTestScene.cs
@@ -265,7 +265,7 @@ namespace osu.Game.Tests.Visual
{
Debug.Assert(original.BeatmapSet != null);
- return new APIBeatmapSet
+ var result = new APIBeatmapSet
{
OnlineID = original.BeatmapSet.OnlineID,
Status = BeatmapOnlineStatus.Ranked,
@@ -301,6 +301,11 @@ namespace osu.Game.Tests.Visual
}
}
};
+
+ foreach (var beatmap in result.Beatmaps)
+ beatmap.BeatmapSet = result;
+
+ return result;
}
protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) =>
diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs
index 0392e3ae52..ee184c1f35 100644
--- a/osu.Game/Tests/Visual/PlayerTestScene.cs
+++ b/osu.Game/Tests/Visual/PlayerTestScene.cs
@@ -12,6 +12,7 @@ using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
+using osu.Game.Screens.Play;
namespace osu.Game.Tests.Visual
{
@@ -79,6 +80,11 @@ namespace osu.Game.Tests.Visual
protected void LoadPlayer(Mod[] mods)
{
+ // if a player screen is present already, we must exit that before loading another one,
+ // otherwise it'll crash on SpectatorClient.BeginPlaying being called while client is in "playing" state already.
+ if (Stack.CurrentScreen is Player)
+ Stack.Exit();
+
var ruleset = CreatePlayerRuleset();
Ruleset.Value = ruleset.RulesetInfo;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index d109345518..d88b568244 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -21,13 +21,13 @@
-
+
-
-
-
-
-
+
+
+
+
+
@@ -35,13 +35,13 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
-
+
+
-
+
diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist
index 0ce1d952d0..cf51fe995b 100644
--- a/osu.iOS/Info.plist
+++ b/osu.iOS/Info.plist
@@ -34,9 +34,9 @@
CADisableMinimumFrameDurationOnPhone
NSCameraUsageDescription
- We don't really use the camera.
+ We don't really use the camera.
NSMicrophoneUsageDescription
- We don't really use the microphone.
+ We don't really use the microphone.
UISupportedInterfaceOrientations
UIInterfaceOrientationLandscapeRight
@@ -130,5 +130,7 @@
Editor
+ LSApplicationCategoryType
+ public.app-category.music-games